mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
39 Commits
feat/remot
...
feat/slide
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dae3e5501d | ||
|
|
708196040a | ||
|
|
65586577a3 | ||
|
|
be1f3621de | ||
|
|
65998a21e3 | ||
|
|
d5afe3f705 | ||
|
|
baf6050f8e | ||
|
|
a6bc81596a | ||
|
|
7f43b7ed5d | ||
|
|
80b3645362 | ||
|
|
64caef1526 | ||
|
|
64e10a0954 | ||
|
|
8897196dee | ||
|
|
49b4ccceb9 | ||
|
|
4b2d012af9 | ||
|
|
90aad64b8d | ||
|
|
2919084103 | ||
|
|
36bd82cb27 | ||
|
|
2e77d8db80 | ||
|
|
d9061ffcbc | ||
|
|
08d9b28ee8 | ||
|
|
168fb13e3e | ||
|
|
55c2e5c819 | ||
|
|
16a93cd277 | ||
|
|
e9dabb2184 | ||
|
|
8acd55e907 | ||
|
|
6ecbfaf690 | ||
|
|
ac2508d3b0 | ||
|
|
1c3674487f | ||
|
|
37d490a198 | ||
|
|
4e44e51bef | ||
|
|
e79d49e7e4 | ||
|
|
83352fe00b | ||
|
|
21bfa84edd | ||
|
|
fc8d212a4f | ||
|
|
35049e8d30 | ||
|
|
d8782e715a | ||
|
|
7675185f9d | ||
|
|
1ab853023a |
45
.github/workflows/ci.yml
vendored
45
.github/workflows/ci.yml
vendored
@@ -47,6 +47,34 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
plugin-integration:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
# No fetch_meta: the git-archive clean tree must embed only the
|
||||
# committed meta_data stub (reproduces the bare-module customer state).
|
||||
- name: Run plugin-integration L4 tests
|
||||
run: go test -count=1 -timeout=15m ./tests/plugin_e2e/...
|
||||
|
||||
sidecar-integration:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Run sidecar tag build + HMAC round-trip
|
||||
run: make sidecar-test
|
||||
|
||||
# ── Layer 2: Quality Gate ──────────────────────────────────────────
|
||||
unit-test:
|
||||
needs: fast-gate
|
||||
@@ -176,7 +204,11 @@ jobs:
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Run tests with coverage
|
||||
run: |
|
||||
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
|
||||
# tests/ holds only L3/L4 suites (cli_e2e, plugin_e2e, sidecar_e2e) that
|
||||
# have dedicated jobs; exclude the whole subtree so none of them runs a
|
||||
# second time here — and, crucially, so an observe-only suite's failure
|
||||
# can never block merges through coverage's spot in the results loop.
|
||||
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/')
|
||||
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
|
||||
- name: Upload coverage to Codecov
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
@@ -416,7 +448,7 @@ jobs:
|
||||
# ── Results Gate (single required check for branch protection) ─────
|
||||
results:
|
||||
if: ${{ always() }}
|
||||
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
|
||||
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header, plugin-integration, sidecar-integration]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Evaluate results
|
||||
@@ -436,10 +468,19 @@ jobs:
|
||||
echo "| L3 | e2e-live | ${{ needs.e2e-live.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | security | ${{ needs.security.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | license-header | ${{ needs.license-header.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | plugin-integration (observe-only) | ${{ needs.plugin-integration.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | sidecar-integration (observe-only) | ${{ needs.sidecar-integration.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Any failure or cancellation in any job blocks the merge.
|
||||
# Legitimately skipped jobs (deadcode on push, e2e-live on fork,
|
||||
# license-header on push) are OK.
|
||||
#
|
||||
# plugin-integration and sidecar-integration are intentionally NOT
|
||||
# in this loop yet: they run on every PR and their status is shown
|
||||
# in the table above, but a failure is observe-only (non-blocking)
|
||||
# during the initial soak. Graduation to required is tracked in
|
||||
# https://github.com/larksuite/cli/issues/1894 (criteria: 4
|
||||
# consecutive weeks with zero false positives).
|
||||
FAILED=0
|
||||
for result in \
|
||||
"${{ needs.fast-gate.result }}" \
|
||||
|
||||
15
AGENTS.md
15
AGENTS.md
@@ -105,6 +105,20 @@ Signatures that are easy to guess wrong:
|
||||
|
||||
Program output (JSON envelopes) goes to stdout. Progress, warnings, hints go to stderr. Mixing them corrupts pipe chains.
|
||||
|
||||
### Typed data over loose maps
|
||||
|
||||
Parse `map[string]interface{}` into a typed struct at the boundary — one projection function per shape — and let everything downstream consume struct fields, not string keys. A typo'd map key compiles fine and fails at runtime, which an agent then debugs blind.
|
||||
|
||||
Use distinct types when two values could be swapped silently: see `internal/meta.Token` — a bare string compiles on either side of a string/string signature, a distinct type does not.
|
||||
|
||||
Legacy loose-map code exists in older paths. Match its call sites when touching it, but do not copy the pattern into new code.
|
||||
|
||||
### Transcribe faithfully — no silent fallbacks
|
||||
|
||||
When code echoes input onward (request previews, transformations, proxies), transcribe verbatim. A `default:` branch that coerces unrecognized input into a plausible value ("unknown HTTP verb → GET") makes the output lie, and an agent reasons from the lie.
|
||||
|
||||
The same rule applies to flag combinations and internal wiring: if a requested option cannot be honored, return a typed validation error — never silently substitute another behavior and exit 0. Silent guesses (defaulting a missing identity, discarding writes on a nil writer) are bugs even when every current caller happens to avoid them.
|
||||
|
||||
### Use `vfs.*` instead of `os.*`
|
||||
|
||||
All filesystem access goes through `internal/vfs`. This enables test mocking.
|
||||
@@ -116,6 +130,7 @@ CLI arguments are untrusted (they come from AI agents). Call `validate.SafeInput
|
||||
### Tests
|
||||
|
||||
- Every behavior change needs a test alongside the change.
|
||||
- A contract test must fail if the implementation is reverted. If you can undo the code change and the suite stays green, the contract is not pinned — assert the new field/behavior directly, not a happy-path substring.
|
||||
- `cmdutil.TestFactory(t, config)` for test factories.
|
||||
- `t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())` to isolate config state.
|
||||
|
||||
|
||||
92
CHANGELOG.md
92
CHANGELOG.md
@@ -2,6 +2,95 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.71] - 2026-07-16
|
||||
|
||||
### Features
|
||||
|
||||
- add wiki move-to-drive shortcut (#1869)
|
||||
- **apps**: add role management shortcuts (#1881)
|
||||
- **drive**: add secure label support and clarify comment location API (#1913)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: improve dashboard shortcut guidance (#1787)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **apps**: add platform SQL authoring guide to the db-execute skill (#1912)
|
||||
|
||||
### Misc
|
||||
|
||||
- add L4 plugin-integration and sidecar-integration CI jobs (#1840)
|
||||
- **drive**: optimize drive +delete workflow (#1909)
|
||||
|
||||
## [v1.0.70] - 2026-07-15
|
||||
|
||||
### Features
|
||||
|
||||
- add minutes permission application shortcut (#1876)
|
||||
- **drive**: support apps in list comments (#1877)
|
||||
- slide style
|
||||
- edit ppt template
|
||||
- **slides**: add sxsd validation to slides lint
|
||||
- **slides**: validate iconpark icon types in slides lint
|
||||
- **slides**: lint before create
|
||||
- **apps**: add automation trigger commands for Miaoda (#1886)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- unify dry-run output contract (#1870)
|
||||
- **skills**: align skill guidance with the typed error contract (#1786)
|
||||
- **slides**: limit slides screenshot page requests
|
||||
- **slides**: detect lark slides text overflow overlap
|
||||
- **vc**: align meeting query scopes by identity (#1850)
|
||||
|
||||
### Documentation
|
||||
|
||||
- clarify task search relevance filters (#1884)
|
||||
- surface minutes permission application in skill description (#1890)
|
||||
- clarify okr progress children (#1861)
|
||||
- **slides**: prefer slides xml-get shortcut
|
||||
- **calendar**: document setting meeting owner via full API (#1903)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **slides**: streamline create workflow and validate SML namespaces
|
||||
|
||||
### Misc
|
||||
|
||||
- **slides**: address PR review feedback
|
||||
|
||||
## [v1.0.69] - 2026-07-13
|
||||
|
||||
### Features
|
||||
|
||||
- support docs fetch selection anchors (#1815)
|
||||
- **apps**: support modern_html app type with TOS publish path and app type querying
|
||||
- **im**: show bot sender display names when reading messages (#1829)
|
||||
- add drive list comments shortcut (#1845)
|
||||
- support wiki sources in drive export (#1802)
|
||||
- add application domain with slash command management shortcuts (#1806)
|
||||
- validate IM idempotency key length (#1797)
|
||||
- surface reply context and mentions in im.message.receive_v1 (#1798)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- route brand-sensitive endpoints through the resolver (#1836)
|
||||
|
||||
### Documentation
|
||||
|
||||
- document OKR block XML guidance (#1648)
|
||||
- refine doubao whiteboard workflow routing (#1841)
|
||||
- clarify Mindnote token handling (#1827)
|
||||
|
||||
### Tests
|
||||
|
||||
- isolate semantic waiver fixtures from wall clock
|
||||
|
||||
### Misc
|
||||
|
||||
- Merge lark sheets development branch (#1833)
|
||||
|
||||
## [v1.0.68] - 2026-07-09
|
||||
|
||||
### Features
|
||||
@@ -1438,6 +1527,9 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.71]: https://github.com/larksuite/cli/releases/tag/v1.0.71
|
||||
[v1.0.70]: https://github.com/larksuite/cli/releases/tag/v1.0.70
|
||||
[v1.0.69]: https://github.com/larksuite/cli/releases/tag/v1.0.69
|
||||
[v1.0.68]: https://github.com/larksuite/cli/releases/tag/v1.0.68
|
||||
[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
|
||||
|
||||
13
Makefile
13
Makefile
@@ -23,7 +23,7 @@ PREFIX ?= /usr/local
|
||||
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
|
||||
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
|
||||
|
||||
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks
|
||||
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test
|
||||
|
||||
all: test
|
||||
|
||||
@@ -64,6 +64,9 @@ examples-build:
|
||||
go build ./extension/platform/examples/audit-observer
|
||||
go build ./extension/platform/examples/readonly-policy
|
||||
|
||||
# ./tests/... includes tests/plugin_e2e, which builds ~20 customer-fork
|
||||
# binaries (~1 min warm; a cold module cache also downloads via GOPROXY).
|
||||
# Deliberate: local `make test` exercises the L4 plugin contract by default.
|
||||
integration-test: build
|
||||
go test -v -count=1 ./tests/...
|
||||
|
||||
@@ -105,6 +108,14 @@ uninstall:
|
||||
clean:
|
||||
rm -f $(BINARY)
|
||||
|
||||
# sidecar-test compiles and runs the authsidecar* build-tagged code that the
|
||||
# default CI matrix never sees (they carry //go:build tags).
|
||||
sidecar-test:
|
||||
go build -tags authsidecar -o /dev/null .
|
||||
go test $(RACE_FLAG) -count=1 -tags authsidecar ./extension/credential/sidecar/ ./extension/transport/sidecar/ ./internal/cmdutil/
|
||||
go test $(RACE_FLAG) -count=1 -tags authsidecar_demo ./sidecar/server-demo/
|
||||
go test $(RACE_FLAG) -count=1 -tags authsidecar ./tests/sidecar_e2e/
|
||||
|
||||
# Run secret-leak checks locally before pushing.
|
||||
# Step 1: check-doc-tokens catches realistic-looking example tokens in reference
|
||||
# docs and asks you to use _EXAMPLE_TOKEN placeholders instead.
|
||||
|
||||
@@ -130,6 +130,13 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
|
||||
stdin := opts.Factory.IOStreams.In
|
||||
fileIO := opts.Factory.ResolveFileIO(opts.Ctx)
|
||||
|
||||
if opts.Method == "" {
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"HTTP method must not be empty").
|
||||
WithHint("pass the verb as the first argument, e.g. lark-cli api GET /open-apis/...").
|
||||
WithParam("<method>")
|
||||
}
|
||||
|
||||
// Validate --file mutual exclusions first.
|
||||
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, opts.Method); err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
@@ -243,9 +250,9 @@ func apiRun(opts *APIOptions) error {
|
||||
|
||||
if opts.DryRun {
|
||||
if fileMeta != nil {
|
||||
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
|
||||
return cmdutil.PrintDryRunWithFile(request, config, dryRunOutputOptions(f, opts), *fileMeta)
|
||||
}
|
||||
return apiDryRun(f, request, config, opts.Format)
|
||||
return apiDryRun(f, request, config, opts)
|
||||
}
|
||||
// Identity info is now included in the JSON envelope; skip stderr printing.
|
||||
// cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected)
|
||||
@@ -297,8 +304,19 @@ func apiRun(opts *APIOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
|
||||
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
|
||||
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *APIOptions) error {
|
||||
return cmdutil.PrintDryRun(request, config, dryRunOutputOptions(f, opts))
|
||||
}
|
||||
|
||||
func dryRunOutputOptions(f *cmdutil.Factory, opts *APIOptions) cmdutil.DryRunOutputOptions {
|
||||
return cmdutil.DryRunOutputOptions{
|
||||
Format: opts.Format,
|
||||
JqExpr: opts.JqExpr,
|
||||
CommandPath: opts.Cmd.CommandPath(),
|
||||
Identity: opts.As,
|
||||
Out: f.IOStreams.Out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
}
|
||||
}
|
||||
|
||||
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions) error {
|
||||
|
||||
@@ -69,7 +69,7 @@ func TestApiCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
@@ -79,12 +79,42 @@ func TestApiCmd_DryRun(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "Dry Run") {
|
||||
t.Error("expected dry run output")
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String())
|
||||
}
|
||||
if !strings.Contains(output, "/open-apis/test") {
|
||||
t.Error("expected path in dry run output")
|
||||
if got["ok"] != true || got["identity"] != "bot" || got["dry_run"] != true {
|
||||
t.Fatalf("unexpected dry-run envelope: %#v", got)
|
||||
}
|
||||
data, ok := got["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("data = %#v, want object", got["data"])
|
||||
}
|
||||
api, ok := data["api"].([]interface{})
|
||||
if !ok || len(api) != 1 {
|
||||
t.Fatalf("api = %#v, want one call", data["api"])
|
||||
}
|
||||
call, ok := api[0].(map[string]interface{})
|
||||
if !ok || call["url"] != "/open-apis/test" {
|
||||
t.Fatalf("api[0] = %#v", api[0])
|
||||
}
|
||||
if strings.Contains(stdout.String(), "=== Dry Run ===") {
|
||||
t.Fatalf("stdout should not contain dry-run banner: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_DryRunWithJq(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run", "--jq", ".data.api[0].url"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(stdout.String()); got != "/open-apis/test" {
|
||||
t.Fatalf("jq output = %q, want /open-apis/test", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +182,22 @@ func TestApiCmd_MissingArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_EmptyMethodRejected(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"", "/open-apis/test", "--as", "bot", "--dry-run"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for empty HTTP method")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "method") {
|
||||
t.Fatalf("error should name the method argument, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_InvalidParamsJSON(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -1000,11 +1046,23 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "image") {
|
||||
t.Errorf("expected dry-run output to mention file field, got: %s", out)
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "Dry Run") {
|
||||
t.Errorf("expected dry-run header, got: %s", out)
|
||||
if env["dry_run"] != true {
|
||||
t.Fatalf("dry_run = %#v, want true", env["dry_run"])
|
||||
}
|
||||
data := env["data"].(map[string]interface{})
|
||||
api := data["api"].([]interface{})
|
||||
call := api[0].(map[string]interface{})
|
||||
body := call["body"].(map[string]interface{})
|
||||
file := body["file"].(map[string]interface{})
|
||||
if file["field"] != "image" || file["path"] != tmpFile {
|
||||
t.Fatalf("unexpected file dry-run body: %#v", body)
|
||||
}
|
||||
if strings.Contains(out, "=== Dry Run ===") {
|
||||
t.Fatalf("stdout should not contain dry-run banner: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -128,5 +128,5 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
|
||||
// (not backed by from_meta service specs). Descriptions are now centralized in
|
||||
// service_descriptions.json.
|
||||
func getShortcutOnlyDomainNames() []string {
|
||||
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
|
||||
return []string{"application", "base", "contact", "docs", "markdown", "apps", "note"}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,40 @@ func TestRunSchema_JSONOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_ReceiveMessageAgentFieldsJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "im.message.receive_v1", true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
resolved := payload["resolved_output_schema"].(map[string]interface{})
|
||||
props := resolved["properties"].(map[string]interface{})
|
||||
for _, field := range []string{
|
||||
"root_id",
|
||||
"thread_id",
|
||||
"reply_to",
|
||||
"sender_type",
|
||||
"mentions",
|
||||
} {
|
||||
if _, ok := props[field]; !ok {
|
||||
t.Errorf("receive schema missing field %q", field)
|
||||
}
|
||||
}
|
||||
msgDesc := props["message_id"].(map[string]interface{})["description"].(string)
|
||||
if !strings.Contains(msgDesc, "Recommended idempotency key") {
|
||||
t.Errorf("message_id description should guide deduplication, got %q", msgDesc)
|
||||
}
|
||||
eventDesc := props["event_id"].(map[string]interface{})["description"].(string)
|
||||
if strings.Contains(eventDesc, "safe for deduplication") {
|
||||
t.Errorf("event_id description should not recommend deduplication, got %q", eventDesc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
|
||||
@@ -403,9 +403,9 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
|
||||
if opts.DryRun {
|
||||
if fileMeta != nil {
|
||||
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
|
||||
return cmdutil.PrintDryRunWithFile(request, config, serviceDryRunOutputOptions(f, opts), *fileMeta)
|
||||
}
|
||||
return serviceDryRun(f, request, config, opts.Format)
|
||||
return serviceDryRun(f, request, config, opts)
|
||||
}
|
||||
|
||||
if opts.Method.Risk == cmdutil.RiskHighRiskWrite {
|
||||
@@ -667,8 +667,19 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
return request, nil, nil
|
||||
}
|
||||
|
||||
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
|
||||
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
|
||||
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *ServiceMethodOptions) error {
|
||||
return cmdutil.PrintDryRun(request, config, serviceDryRunOutputOptions(f, opts))
|
||||
}
|
||||
|
||||
func serviceDryRunOutputOptions(f *cmdutil.Factory, opts *ServiceMethodOptions) cmdutil.DryRunOutputOptions {
|
||||
return cmdutil.DryRunOutputOptions{
|
||||
Format: opts.Format,
|
||||
JqExpr: opts.JqExpr,
|
||||
CommandPath: opts.Cmd.CommandPath(),
|
||||
Identity: opts.As,
|
||||
Out: f.IOStreams.Out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
}
|
||||
}
|
||||
|
||||
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
|
||||
|
||||
@@ -224,13 +224,39 @@ func TestServiceMethod_DryRun_PathParam(t *testing.T) {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), tt.wantInURL) {
|
||||
t.Errorf("expected URL containing %q, got:\n%s", tt.wantInURL, stdout.String())
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if got["ok"] != true || got["dry_run"] != true {
|
||||
t.Fatalf("unexpected dry-run envelope: %#v", got)
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
api := data["api"].([]interface{})
|
||||
call := api[0].(map[string]interface{})
|
||||
if call["url"] != tt.wantInURL {
|
||||
t.Errorf("url = %q, want %q\nstdout:\n%s", call["url"], tt.wantInURL, stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_DryRunWithJq(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("GET", nil), "get", "files", nil)
|
||||
cmd.SetArgs([]string{
|
||||
"--params", `{"file_token":"boxcn123abc"}`,
|
||||
"--dry-run",
|
||||
"--jq", ".data.api[0].url",
|
||||
})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got, want := strings.TrimSpace(stdout.String()), "/open-apis/drive/v1/files/boxcn123abc/copy"; got != want {
|
||||
t.Fatalf("jq output = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_PathParamRejectsTraversal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -318,8 +344,12 @@ func TestServiceMethod_PaginationParamSkippedWithPageAll(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error with --page-all skipping page_size, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Dry Run") {
|
||||
t.Error("expected dry-run output")
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if got["dry_run"] != true {
|
||||
t.Fatalf("dry_run = %#v, want true", got["dry_run"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1081,11 +1111,23 @@ func TestServiceMethod_FileUpload_DryRun(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "image") {
|
||||
t.Errorf("expected dry-run output to mention file field, got: %s", out)
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "Dry Run") {
|
||||
t.Errorf("expected dry-run header, got: %s", out)
|
||||
if env["dry_run"] != true {
|
||||
t.Fatalf("dry_run = %#v, want true", env["dry_run"])
|
||||
}
|
||||
data := env["data"].(map[string]interface{})
|
||||
api := data["api"].([]interface{})
|
||||
call := api[0].(map[string]interface{})
|
||||
body := call["body"].(map[string]interface{})
|
||||
file := body["file"].(map[string]interface{})
|
||||
if file["field"] != "image" || file["path"] != tmpFile {
|
||||
t.Fatalf("unexpected file dry-run body: %#v", body)
|
||||
}
|
||||
if strings.Contains(out, "=== Dry Run ===") {
|
||||
t.Fatalf("stdout should not contain dry-run banner: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,17 +13,29 @@ import (
|
||||
|
||||
// ImMessageReceiveOutput is the flattened shape for im.message.receive_v1; `desc` tags drive the reflected schema.
|
||||
type ImMessageReceiveOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always im.message.receive_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); prefers header.create_time" kind:"timestamp_ms"`
|
||||
ID string `json:"id,omitempty" desc:"Message ID (legacy alias of message_id, kept for compatibility)" kind:"message_id"`
|
||||
MessageID string `json:"message_id,omitempty" desc:"Message ID; prefixed with om_" kind:"message_id"`
|
||||
CreateTime string `json:"create_time,omitempty" desc:"Message creation time (ms timestamp string)" kind:"timestamp_ms"`
|
||||
ChatID string `json:"chat_id,omitempty" desc:"Chat/conversation ID; prefixed with oc_" kind:"chat_id"`
|
||||
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
|
||||
MessageType string `json:"message_type,omitempty" desc:"Message type"`
|
||||
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
|
||||
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."`
|
||||
Type string `json:"type" desc:"Event type; always im.message.receive_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Event delivery ID. Do not use as the message deduplication key; use message_id instead."`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); prefers header.create_time" kind:"timestamp_ms"`
|
||||
ID string `json:"id,omitempty" desc:"Message ID (legacy alias of message_id, kept for compatibility)" kind:"message_id"`
|
||||
MessageID string `json:"message_id,omitempty" desc:"Message ID; prefixed with om_. Recommended idempotency key for im.message.receive_v1 consumers." kind:"message_id"`
|
||||
CreateTime string `json:"create_time,omitempty" desc:"Message creation time (ms timestamp string)" kind:"timestamp_ms"`
|
||||
UpdateTime string `json:"update_time,omitempty" desc:"Message update time (ms timestamp string); emitted only when different from create_time" kind:"timestamp_ms"`
|
||||
ChatID string `json:"chat_id,omitempty" desc:"Chat/conversation ID; prefixed with oc_" kind:"chat_id"`
|
||||
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
|
||||
MessageType string `json:"message_type,omitempty" desc:"Message type"`
|
||||
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
|
||||
SenderType string `json:"sender_type,omitempty" desc:"Sender type" enum:"user,bot"`
|
||||
RootID string `json:"root_id,omitempty" desc:"Root message ID of the reply/thread context, when present" kind:"message_id"`
|
||||
ThreadID string `json:"thread_id,omitempty" desc:"Thread ID, when present"`
|
||||
ReplyTo string `json:"reply_to,omitempty" desc:"Parent message ID of the direct reply context, when present" kind:"message_id"`
|
||||
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."`
|
||||
Mentions []MentionOutput `json:"mentions,omitempty" desc:"Compact mentions aligned with im +messages-mget"`
|
||||
}
|
||||
|
||||
type MentionOutput struct {
|
||||
Key string `json:"key,omitempty" desc:"Mention placeholder key, for example @_user_1"`
|
||||
ID string `json:"id,omitempty" desc:"Mentioned user open_id; prefixed with ou_" kind:"open_id"`
|
||||
Name string `json:"name,omitempty" desc:"Mentioned display name"`
|
||||
}
|
||||
|
||||
func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
@@ -36,15 +48,20 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
|
||||
Event struct {
|
||||
Message struct {
|
||||
MessageID string `json:"message_id"`
|
||||
RootID string `json:"root_id"`
|
||||
ParentID string `json:"parent_id"`
|
||||
ThreadID string `json:"thread_id"`
|
||||
ChatID string `json:"chat_id"`
|
||||
ChatType string `json:"chat_type"`
|
||||
MessageType string `json:"message_type"`
|
||||
Content string `json:"content"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Mentions []interface{} `json:"mentions"`
|
||||
} `json:"message"`
|
||||
Sender struct {
|
||||
SenderID struct {
|
||||
SenderType string `json:"sender_type"`
|
||||
SenderID struct {
|
||||
OpenID string `json:"open_id"`
|
||||
} `json:"sender_id"`
|
||||
} `json:"sender"`
|
||||
@@ -81,7 +98,54 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
|
||||
ChatType: msg.ChatType,
|
||||
MessageType: msg.MessageType,
|
||||
SenderID: envelope.Event.Sender.SenderID.OpenID,
|
||||
SenderType: envelope.Event.Sender.SenderType,
|
||||
RootID: msg.RootID,
|
||||
ThreadID: msg.ThreadID,
|
||||
ReplyTo: msg.ParentID,
|
||||
Content: content,
|
||||
Mentions: compactMentions(msg.Mentions),
|
||||
}
|
||||
if msg.UpdateTime != "" && msg.UpdateTime != msg.CreateTime {
|
||||
out.UpdateTime = msg.UpdateTime
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func compactMentions(mentions []interface{}) []MentionOutput {
|
||||
if len(mentions) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]MentionOutput, 0, len(mentions))
|
||||
for _, raw := range mentions {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
mention := MentionOutput{
|
||||
Key: stringField(item, "key"),
|
||||
ID: mentionOpenID(item["id"]),
|
||||
Name: stringField(item, "name"),
|
||||
}
|
||||
if mention.Key != "" || mention.ID != "" || mention.Name != "" {
|
||||
out = append(out, mention)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stringField(m map[string]interface{}, key string) string {
|
||||
v, _ := m[key].(string)
|
||||
return v
|
||||
}
|
||||
|
||||
func mentionOpenID(raw interface{}) string {
|
||||
switch v := raw.(type) {
|
||||
case map[string]interface{}:
|
||||
openID, _ := v["open_id"].(string)
|
||||
return openID
|
||||
case string:
|
||||
return v
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,19 +84,32 @@ func TestProcessImMessageReceive_Text(t *testing.T) {
|
||||
},
|
||||
"event": {
|
||||
"sender": {
|
||||
"sender_type": "user",
|
||||
"sender_id": {"open_id": "ou_sender"}
|
||||
},
|
||||
"message": {
|
||||
"message_id": "om_text_001",
|
||||
"root_id": "om_root_001",
|
||||
"parent_id": "om_parent_001",
|
||||
"thread_id": "omt_thread_001",
|
||||
"chat_id": "oc_chat",
|
||||
"chat_type": "p2p",
|
||||
"message_type": "text",
|
||||
"create_time": "1776409468987",
|
||||
"content": "{\"text\":\"hello there\"}"
|
||||
"update_time": "1776409469999",
|
||||
"content": "{\"text\":\"hello @_user_1\"}",
|
||||
"mentions": [
|
||||
{
|
||||
"key": "@_user_1",
|
||||
"id": {"open_id": "ou_mentioned"},
|
||||
"name": "Alice"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runReceive(t, payload)
|
||||
outMap := runReceiveMap(t, payload)
|
||||
|
||||
if out.Type != "im.message.receive_v1" {
|
||||
t.Errorf("Type = %q", out.Type)
|
||||
@@ -110,12 +123,69 @@ func TestProcessImMessageReceive_Text(t *testing.T) {
|
||||
if out.SenderID != "ou_sender" {
|
||||
t.Errorf("SenderID = %q", out.SenderID)
|
||||
}
|
||||
if out.Content != "hello there" {
|
||||
t.Errorf("Content = %q, want \"hello there\"", out.Content)
|
||||
if out.Content != "hello @Alice" {
|
||||
t.Errorf("Content = %q, want \"hello @Alice\"", out.Content)
|
||||
}
|
||||
if out.Timestamp != "1776409469273" {
|
||||
t.Errorf("Timestamp = %q", out.Timestamp)
|
||||
}
|
||||
for field, want := range map[string]string{
|
||||
"sender_type": "user",
|
||||
"root_id": "om_root_001",
|
||||
"thread_id": "omt_thread_001",
|
||||
"reply_to": "om_parent_001",
|
||||
"update_time": "1776409469999",
|
||||
} {
|
||||
if got, _ := outMap[field].(string); got != want {
|
||||
t.Errorf("%s = %q, want %q", field, got, want)
|
||||
}
|
||||
}
|
||||
mentions, _ := outMap["mentions"].([]interface{})
|
||||
if len(mentions) != 1 {
|
||||
t.Fatalf("mentions length = %d, want 1: %#v", len(mentions), outMap["mentions"])
|
||||
}
|
||||
mention, _ := mentions[0].(map[string]interface{})
|
||||
for field, want := range map[string]string{
|
||||
"key": "@_user_1",
|
||||
"id": "ou_mentioned",
|
||||
"name": "Alice",
|
||||
} {
|
||||
if got, _ := mention[field].(string); got != want {
|
||||
t.Errorf("mentions[0].%s = %q, want %q", field, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessImMessageReceive_OmitsUnchangedUpdateTime(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_test_text",
|
||||
"event_type": "im.message.receive_v1",
|
||||
"create_time": "1776409469273",
|
||||
"app_id": "cli_test"
|
||||
},
|
||||
"event": {
|
||||
"sender": {
|
||||
"sender_type": "user",
|
||||
"sender_id": {"open_id": "ou_sender"}
|
||||
},
|
||||
"message": {
|
||||
"message_id": "om_text_001",
|
||||
"chat_id": "oc_chat",
|
||||
"chat_type": "p2p",
|
||||
"message_type": "text",
|
||||
"create_time": "1776409468987",
|
||||
"update_time": "1776409468987",
|
||||
"content": "{\"text\":\"hello there\"}"
|
||||
}
|
||||
}
|
||||
}`
|
||||
outMap := runReceiveMap(t, payload)
|
||||
|
||||
if _, ok := outMap["update_time"]; ok {
|
||||
t.Errorf("update_time should be omitted when it equals create_time: %#v", outMap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessImMessageReceive_Interactive(t *testing.T) {
|
||||
@@ -188,3 +258,22 @@ func runReceive(t *testing.T, payload string) ImMessageReceiveOutput {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func runReceiveMap(t *testing.T, payload string) map[string]interface{} {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_test",
|
||||
EventType: "im.message.receive_v1",
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processImMessageReceive(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process error: %v", err)
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -8,15 +8,29 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
|
||||
var dryRunURLPlaceholderRE = regexp.MustCompile(`:([A-Za-z_][A-Za-z0-9_]*)`)
|
||||
|
||||
// DryRunOutputOptions controls dry-run stdout/stderr rendering.
|
||||
type DryRunOutputOptions struct {
|
||||
Format string
|
||||
JqExpr string
|
||||
CommandPath string
|
||||
Identity core.Identity
|
||||
Out io.Writer
|
||||
ErrOut io.Writer
|
||||
}
|
||||
|
||||
// DryRunAPICall describes a single API call in dry-run output.
|
||||
type DryRunAPICall struct {
|
||||
Desc string `json:"desc,omitempty"`
|
||||
@@ -26,12 +40,21 @@ type DryRunAPICall struct {
|
||||
Body interface{} `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
// DryRunContext is the execution context shared by every dry-run preview:
|
||||
// which app would make the call and, when known, as which user. The identity
|
||||
// itself lives at the envelope top level, not here.
|
||||
type DryRunContext struct {
|
||||
AppID string `json:"app_id,omitempty"`
|
||||
UserOpenID string `json:"user_open_id,omitempty"`
|
||||
}
|
||||
|
||||
// DryRunAPI is the builder and result type for dry-run output.
|
||||
// URL templates use :param placeholders; Set stores actual values; MarshalJSON and Format resolve them.
|
||||
type DryRunAPI struct {
|
||||
desc string
|
||||
calls []DryRunAPICall
|
||||
extra map[string]interface{}
|
||||
desc string
|
||||
calls []DryRunAPICall
|
||||
context *DryRunContext
|
||||
extra map[string]interface{}
|
||||
}
|
||||
|
||||
func NewDryRunAPI() *DryRunAPI {
|
||||
@@ -40,30 +63,22 @@ func NewDryRunAPI() *DryRunAPI {
|
||||
|
||||
// --- HTTP method builders (add a call, return self for chaining) ---
|
||||
|
||||
func (d *DryRunAPI) GET(url string) *DryRunAPI {
|
||||
d.calls = append(d.calls, DryRunAPICall{Method: "GET", URL: url})
|
||||
// call appends a request with the method transcribed verbatim, so previews
|
||||
// never misreport what the real client would send.
|
||||
func (d *DryRunAPI) call(method, url string) *DryRunAPI {
|
||||
d.calls = append(d.calls, DryRunAPICall{Method: method, URL: url})
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *DryRunAPI) POST(url string) *DryRunAPI {
|
||||
d.calls = append(d.calls, DryRunAPICall{Method: "POST", URL: url})
|
||||
return d
|
||||
}
|
||||
func (d *DryRunAPI) GET(url string) *DryRunAPI { return d.call("GET", url) }
|
||||
|
||||
func (d *DryRunAPI) PUT(url string) *DryRunAPI {
|
||||
d.calls = append(d.calls, DryRunAPICall{Method: "PUT", URL: url})
|
||||
return d
|
||||
}
|
||||
func (d *DryRunAPI) POST(url string) *DryRunAPI { return d.call("POST", url) }
|
||||
|
||||
func (d *DryRunAPI) DELETE(url string) *DryRunAPI {
|
||||
d.calls = append(d.calls, DryRunAPICall{Method: "DELETE", URL: url})
|
||||
return d
|
||||
}
|
||||
func (d *DryRunAPI) PUT(url string) *DryRunAPI { return d.call("PUT", url) }
|
||||
|
||||
func (d *DryRunAPI) PATCH(url string) *DryRunAPI {
|
||||
d.calls = append(d.calls, DryRunAPICall{Method: "PATCH", URL: url})
|
||||
return d
|
||||
}
|
||||
func (d *DryRunAPI) DELETE(url string) *DryRunAPI { return d.call("DELETE", url) }
|
||||
|
||||
func (d *DryRunAPI) PATCH(url string) *DryRunAPI { return d.call("PATCH", url) }
|
||||
|
||||
// Body sets the request body on the last added call.
|
||||
func (d *DryRunAPI) Body(body interface{}) *DryRunAPI {
|
||||
@@ -98,12 +113,26 @@ func (d *DryRunAPI) Set(key string, value interface{}) *DryRunAPI {
|
||||
return d
|
||||
}
|
||||
|
||||
// Context records the calling app/user under data.context; empty values are
|
||||
// omitted, and a fully empty context is not emitted at all.
|
||||
func (d *DryRunAPI) Context(appID, userOpenID string) *DryRunAPI {
|
||||
if appID == "" && userOpenID == "" {
|
||||
return d
|
||||
}
|
||||
d.context = &DryRunContext{AppID: appID, UserOpenID: userOpenID}
|
||||
return d
|
||||
}
|
||||
|
||||
// resolveURL replaces :key placeholders in url with path-escaped values from extra.
|
||||
func (d *DryRunAPI) resolveURL(rawURL string) string {
|
||||
for k, v := range d.extra {
|
||||
rawURL = strings.ReplaceAll(rawURL, ":"+k, url.PathEscape(fmt.Sprintf("%v", v)))
|
||||
}
|
||||
return rawURL
|
||||
return dryRunURLPlaceholderRE.ReplaceAllStringFunc(rawURL, func(token string) string {
|
||||
name := token[1:]
|
||||
value, ok := d.extra[name]
|
||||
if !ok {
|
||||
return token
|
||||
}
|
||||
return url.PathEscape(fmt.Sprintf("%v", value))
|
||||
})
|
||||
}
|
||||
|
||||
// MarshalJSON serializes as {"description": "...", "api": [...calls with resolved URLs], ...extra}.
|
||||
@@ -118,13 +147,17 @@ func (d *DryRunAPI) MarshalJSON() ([]byte, error) {
|
||||
Body: c.Body,
|
||||
}
|
||||
}
|
||||
m := make(map[string]interface{}, len(d.extra)+2)
|
||||
m := make(map[string]interface{}, len(d.extra)+3)
|
||||
for k, v := range d.extra {
|
||||
m[k] = v
|
||||
}
|
||||
// Typed fields win over same-named extra keys.
|
||||
if d.desc != "" {
|
||||
m["description"] = d.desc
|
||||
}
|
||||
m["api"] = resolved
|
||||
for k, v := range d.extra {
|
||||
m[k] = v
|
||||
if d.context != nil {
|
||||
m["context"] = d.context
|
||||
}
|
||||
return json.Marshal(m)
|
||||
}
|
||||
@@ -154,11 +187,7 @@ func (d *DryRunAPI) Format() string {
|
||||
u += "?" + encodeParams(c.Params)
|
||||
}
|
||||
|
||||
method := c.Method
|
||||
if method == "" {
|
||||
method = "GET"
|
||||
}
|
||||
b.WriteString(method)
|
||||
b.WriteString(c.Method)
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(u)
|
||||
b.WriteByte('\n')
|
||||
@@ -215,83 +244,74 @@ func encodeParams(params map[string]interface{}) string {
|
||||
return vals.Encode()
|
||||
}
|
||||
|
||||
// PrintDryRunWithFile outputs a dry-run summary for file upload requests.
|
||||
// Instead of serializing the Formdata body, it shows file metadata.
|
||||
func PrintDryRunWithFile(w io.Writer, request client.RawApiRequest, config *core.CliConfig, format, fileField, filePath string, formFields any) error {
|
||||
dr := NewDryRunAPI()
|
||||
switch request.Method {
|
||||
case "POST":
|
||||
dr.POST(request.URL)
|
||||
case "PUT":
|
||||
dr.PUT(request.URL)
|
||||
case "PATCH":
|
||||
dr.PATCH(request.URL)
|
||||
case "DELETE":
|
||||
dr.DELETE(request.URL)
|
||||
default:
|
||||
dr.GET(request.URL)
|
||||
}
|
||||
// buildDryRunPreview assembles the shared preview skeleton: HTTP method, URL,
|
||||
// query params, and the app/user context common to every dry-run.
|
||||
func buildDryRunPreview(request client.RawApiRequest, config *core.CliConfig) *DryRunAPI {
|
||||
dr := NewDryRunAPI().call(request.Method, request.URL)
|
||||
if len(request.Params) > 0 {
|
||||
dr.Params(request.Params)
|
||||
}
|
||||
filePathDisplay := filePath
|
||||
// Identity is reported at the envelope top level, not duplicated here.
|
||||
dr.Context(config.AppID, config.UserOpenId)
|
||||
return dr
|
||||
}
|
||||
|
||||
// PrintDryRunWithFile outputs a dry-run summary for file upload requests.
|
||||
// Instead of serializing the Formdata body, it shows file metadata.
|
||||
func PrintDryRunWithFile(request client.RawApiRequest, config *core.CliConfig, opts DryRunOutputOptions, file FileUploadMeta) error {
|
||||
dr := buildDryRunPreview(request, config)
|
||||
filePathDisplay := file.FilePath
|
||||
if filePathDisplay == "" {
|
||||
filePathDisplay = "<stdin>"
|
||||
}
|
||||
fileInfo := map[string]any{
|
||||
"file": map[string]string{"field": fileField, "path": filePathDisplay},
|
||||
"file": map[string]string{"field": file.FieldName, "path": filePathDisplay},
|
||||
}
|
||||
if formFields != nil {
|
||||
fileInfo["form_fields"] = formFields
|
||||
if file.FormFields != nil {
|
||||
fileInfo["form_fields"] = file.FormFields
|
||||
}
|
||||
fileInfo["options"] = []string{"WithFileUpload"}
|
||||
dr.Body(fileInfo)
|
||||
dr.Set("as", string(request.As))
|
||||
dr.Set("appId", config.AppID)
|
||||
if config.UserOpenId != "" {
|
||||
dr.Set("userOpenId", config.UserOpenId)
|
||||
}
|
||||
fmt.Fprintln(w, "=== Dry Run ===")
|
||||
if format == "pretty" {
|
||||
fmt.Fprint(w, dr.Format())
|
||||
} else {
|
||||
output.PrintJson(w, dr)
|
||||
}
|
||||
return nil
|
||||
return WriteDryRun(dr, opts)
|
||||
}
|
||||
|
||||
// PrintDryRun outputs a standardised dry-run summary using DryRunAPI.
|
||||
// When format is "pretty", outputs human-readable text; otherwise JSON.
|
||||
func PrintDryRun(w io.Writer, request client.RawApiRequest, config *core.CliConfig, format string) error {
|
||||
dr := NewDryRunAPI()
|
||||
switch request.Method {
|
||||
case "POST":
|
||||
dr.POST(request.URL)
|
||||
case "PUT":
|
||||
dr.PUT(request.URL)
|
||||
case "PATCH":
|
||||
dr.PATCH(request.URL)
|
||||
case "DELETE":
|
||||
dr.DELETE(request.URL)
|
||||
default:
|
||||
dr.GET(request.URL)
|
||||
}
|
||||
if len(request.Params) > 0 {
|
||||
dr.Params(request.Params)
|
||||
}
|
||||
func PrintDryRun(request client.RawApiRequest, config *core.CliConfig, opts DryRunOutputOptions) error {
|
||||
dr := buildDryRunPreview(request, config)
|
||||
if !util.IsNil(request.Data) {
|
||||
dr.Body(request.Data)
|
||||
}
|
||||
dr.Set("as", string(request.As))
|
||||
dr.Set("appId", config.AppID)
|
||||
if config.UserOpenId != "" {
|
||||
dr.Set("userOpenId", config.UserOpenId)
|
||||
}
|
||||
fmt.Fprintln(w, "=== Dry Run ===")
|
||||
if format == "pretty" {
|
||||
fmt.Fprint(w, dr.Format())
|
||||
} else {
|
||||
output.PrintJson(w, dr)
|
||||
}
|
||||
return nil
|
||||
return WriteDryRun(dr, opts)
|
||||
}
|
||||
|
||||
// WriteDryRun emits a DryRunAPI using the shared dry-run output contract.
|
||||
// Identity may be empty; the envelope omits it rather than guessing.
|
||||
func WriteDryRun(dr *DryRunAPI, opts DryRunOutputOptions) error {
|
||||
if dr == nil {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "dry-run produced no request preview")
|
||||
}
|
||||
// The JqExpr guard is defensive: every entry point already rejects --jq
|
||||
// combined with --format pretty via output.ValidateJqFlags.
|
||||
if opts.Format == "pretty" && opts.JqExpr == "" {
|
||||
// A nil ErrOut only skips the banner decoration (mirroring
|
||||
// WriteSuccessEnvelope's warning path); the payload write to Out
|
||||
// must fail loudly rather than be silently discarded.
|
||||
if opts.ErrOut != nil {
|
||||
fmt.Fprintln(opts.ErrOut, "=== Dry Run ===")
|
||||
}
|
||||
// stdout carries its own marker so logs that drop stderr still show
|
||||
// this was a preview, not an executed request.
|
||||
fmt.Fprintln(opts.Out, "# dry-run: request not sent")
|
||||
fmt.Fprint(opts.Out, dr.Format())
|
||||
return nil
|
||||
}
|
||||
return output.WriteSuccessEnvelope(dr, output.SuccessEnvelopeOptions{
|
||||
CommandPath: opts.CommandPath,
|
||||
Identity: string(opts.Identity),
|
||||
DryRun: true,
|
||||
JqExpr: opts.JqExpr,
|
||||
Out: opts.Out,
|
||||
ErrOut: opts.ErrOut,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,9 +6,12 @@ package cmdutil
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
@@ -66,11 +69,31 @@ func TestDryRunAPI_ResolveURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunAPI_ResolveURLMatchesFullPlaceholderOnly(t *testing.T) {
|
||||
dr := NewDryRunAPI().
|
||||
GET("/open-apis/task/v2/tasks/:assignee_id").
|
||||
Set("assignee", "ou_bot")
|
||||
|
||||
text := dr.Format()
|
||||
if strings.Contains(text, "ou_bot_id") {
|
||||
t.Fatalf("prefix placeholder key corrupted longer token: %s", text)
|
||||
}
|
||||
if !strings.Contains(text, ":assignee_id") {
|
||||
t.Fatalf("missing unresolved placeholder, got: %s", text)
|
||||
}
|
||||
|
||||
dr.Set("assignee_id", "ou_abc/123")
|
||||
text = dr.Format()
|
||||
if !strings.Contains(text, "/open-apis/task/v2/tasks/ou_abc%2F123") {
|
||||
t.Fatalf("expected full placeholder replacement with path escaping, got: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunAPI_MarshalJSON(t *testing.T) {
|
||||
dr := NewDryRunAPI().
|
||||
Desc("test api").
|
||||
GET("/open-apis/test").
|
||||
Set("as", "user")
|
||||
Set("note", "audit")
|
||||
|
||||
data, err := json.Marshal(dr)
|
||||
if err != nil {
|
||||
@@ -83,8 +106,8 @@ func TestDryRunAPI_MarshalJSON(t *testing.T) {
|
||||
if m["description"] != "test api" {
|
||||
t.Errorf("expected description, got: %v", m["description"])
|
||||
}
|
||||
if m["as"] != "user" {
|
||||
t.Errorf("expected as=user, got: %v", m["as"])
|
||||
if m["note"] != "audit" {
|
||||
t.Errorf("expected note=audit, got: %v", m["note"])
|
||||
}
|
||||
api, ok := m["api"].([]interface{})
|
||||
if !ok || len(api) != 1 {
|
||||
@@ -123,31 +146,67 @@ func TestDryRunAPI_ExtraFieldsOnly(t *testing.T) {
|
||||
|
||||
func TestPrintDryRun_JSON(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := PrintDryRun(&buf, client.RawApiRequest{
|
||||
var errBuf bytes.Buffer
|
||||
err := PrintDryRun(client.RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/test",
|
||||
As: "user",
|
||||
}, &core.CliConfig{AppID: "app123"}, "json")
|
||||
}, &core.CliConfig{AppID: "app123"}, DryRunOutputOptions{
|
||||
Format: "json",
|
||||
CommandPath: "lark-cli api",
|
||||
Identity: core.AsUser,
|
||||
Out: &buf,
|
||||
ErrOut: &errBuf,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrintDryRun failed: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "=== Dry Run ===") {
|
||||
t.Errorf("expected header, got: %s", out)
|
||||
if strings.Contains(out, "=== Dry Run ===") {
|
||||
t.Fatalf("JSON stdout must not contain banner, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "app123") {
|
||||
t.Errorf("expected appId in output, got: %s", out)
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, out)
|
||||
}
|
||||
if env["ok"] != true || env["identity"] != "user" || env["dry_run"] != true {
|
||||
t.Fatalf("unexpected envelope: %#v", env)
|
||||
}
|
||||
data, ok := env["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("unexpected data: %#v", env["data"])
|
||||
}
|
||||
dctx, ok := data["context"].(map[string]interface{})
|
||||
if !ok || dctx["app_id"] != "app123" {
|
||||
t.Fatalf("unexpected data.context: %#v", data["context"])
|
||||
}
|
||||
if _, exists := data["as"]; exists {
|
||||
t.Fatalf("data.as must not appear; identity lives at the envelope top level: %#v", data)
|
||||
}
|
||||
api, ok := data["api"].([]interface{})
|
||||
if !ok || len(api) != 1 {
|
||||
t.Fatalf("api = %#v, want one call", data["api"])
|
||||
}
|
||||
call, ok := api[0].(map[string]interface{})
|
||||
if !ok || call["url"] != "/open-apis/test" {
|
||||
t.Fatalf("api[0] = %#v", api[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintDryRun_Pretty(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := PrintDryRun(&buf, client.RawApiRequest{
|
||||
var errBuf bytes.Buffer
|
||||
err := PrintDryRun(client.RawApiRequest{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/test",
|
||||
Data: map[string]interface{}{"key": "val"},
|
||||
As: "bot",
|
||||
}, &core.CliConfig{AppID: "app456"}, "pretty")
|
||||
}, &core.CliConfig{AppID: "app456"}, DryRunOutputOptions{
|
||||
Format: "pretty",
|
||||
Identity: core.AsBot,
|
||||
Out: &buf,
|
||||
ErrOut: &errBuf,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrintDryRun failed: %v", err)
|
||||
}
|
||||
@@ -155,6 +214,136 @@ func TestPrintDryRun_Pretty(t *testing.T) {
|
||||
if !strings.Contains(out, "POST /open-apis/test") {
|
||||
t.Errorf("expected POST line in pretty output, got: %s", out)
|
||||
}
|
||||
if !strings.HasPrefix(out, "# dry-run: request not sent\n") {
|
||||
t.Fatalf("pretty stdout should start with the dry-run marker, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "=== Dry Run ===") {
|
||||
t.Fatalf("pretty stdout must not contain banner, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "=== Dry Run ===") {
|
||||
t.Fatalf("pretty stderr should contain banner, got: %s", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintDryRun_WithJqUsesEnvelope(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := PrintDryRun(client.RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/test",
|
||||
As: "bot",
|
||||
}, &core.CliConfig{AppID: "app123"}, DryRunOutputOptions{
|
||||
Format: "json",
|
||||
JqExpr: ".data.api[0].url",
|
||||
Identity: core.AsBot,
|
||||
Out: &buf,
|
||||
ErrOut: io.Discard,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrintDryRun failed: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(buf.String()); got != "/open-apis/test" {
|
||||
t.Fatalf("jq output = %q, want /open-apis/test", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintDryRunWithFile_JSONEnvelope(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := PrintDryRunWithFile(client.RawApiRequest{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
As: "bot",
|
||||
}, &core.CliConfig{AppID: "app123", UserOpenId: "ou_tester"}, DryRunOutputOptions{
|
||||
Format: "json",
|
||||
Identity: core.AsBot,
|
||||
Out: &buf,
|
||||
ErrOut: io.Discard,
|
||||
}, FileUploadMeta{FieldName: "file", FilePath: "report.txt", FormFields: map[string]any{"parent": "fld"}})
|
||||
if err != nil {
|
||||
t.Fatalf("PrintDryRunWithFile failed: %v", err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
if env["dry_run"] != true {
|
||||
t.Fatalf("dry_run = %#v, want true", env["dry_run"])
|
||||
}
|
||||
data := env["data"].(map[string]interface{})
|
||||
api := data["api"].([]interface{})
|
||||
call := api[0].(map[string]interface{})
|
||||
body := call["body"].(map[string]interface{})
|
||||
file := body["file"].(map[string]interface{})
|
||||
if file["path"] != "report.txt" {
|
||||
t.Fatalf("file body = %#v", body)
|
||||
}
|
||||
dctx, ok := data["context"].(map[string]interface{})
|
||||
if !ok || dctx["app_id"] != "app123" || dctx["user_open_id"] != "ou_tester" {
|
||||
t.Fatalf("unexpected data.context: %#v", data["context"])
|
||||
}
|
||||
for _, legacy := range []string{"as", "appId", "userOpenId"} {
|
||||
if _, exists := data[legacy]; exists {
|
||||
t.Fatalf("legacy key %q must not appear in data: %#v", legacy, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintDryRun_MethodTranscribedVerbatim(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := PrintDryRun(client.RawApiRequest{
|
||||
Method: "OPTIONS",
|
||||
URL: "/open-apis/test",
|
||||
As: "bot",
|
||||
}, &core.CliConfig{AppID: "app123"}, DryRunOutputOptions{
|
||||
Format: "json",
|
||||
Identity: core.AsBot,
|
||||
Out: &buf,
|
||||
ErrOut: io.Discard,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrintDryRun failed: %v", err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
call := env["data"].(map[string]interface{})["api"].([]interface{})[0].(map[string]interface{})
|
||||
if call["method"] != "OPTIONS" {
|
||||
t.Fatalf("method = %#v, want OPTIONS transcribed verbatim (not coerced to GET)", call["method"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintDryRun_EmptyConfigOmitsContext(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := PrintDryRun(client.RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/test",
|
||||
}, &core.CliConfig{}, DryRunOutputOptions{
|
||||
Format: "json",
|
||||
Out: &buf,
|
||||
ErrOut: io.Discard,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrintDryRun failed: %v", err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
data := env["data"].(map[string]interface{})
|
||||
if _, exists := data["context"]; exists {
|
||||
t.Fatalf("empty app/user context must be omitted entirely, got: %#v", data["context"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteDryRun_NilPreviewIsInternalError(t *testing.T) {
|
||||
err := WriteDryRun(nil, DryRunOutputOptions{Format: "json", Out: io.Discard})
|
||||
if err == nil {
|
||||
t.Fatal("WriteDryRun(nil) should fail instead of emitting an empty preview")
|
||||
}
|
||||
var internal *errs.InternalError
|
||||
if !errors.As(err, &internal) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunFormatValue(t *testing.T) {
|
||||
|
||||
28
internal/errclass/codemeta_spark.go
Normal file
28
internal/errclass/codemeta_spark.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// sparkCodeMeta holds stable Spark app-role business-code classifications.
|
||||
// Command-specific recovery guidance belongs in the Apps shortcut layer; the
|
||||
// numeric code remains the source-specific discriminator on the error envelope.
|
||||
var sparkCodeMeta = map[int]CodeMeta{
|
||||
3340001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // request parameters are invalid
|
||||
3344027: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // role user count exceeds the service limit
|
||||
3344028: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // role department count exceeds the service limit
|
||||
3344029: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // role chat count exceeds the service limit
|
||||
3344030: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // app administrator required
|
||||
3344031: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // app administrator or developer required
|
||||
3344034: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid role ID
|
||||
3344035: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // role does not exist
|
||||
3344036: {Category: errs.CategoryAPI, Subtype: errs.SubtypeAlreadyExists}, // role ID already exists
|
||||
3344037: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // app role count exceeds the service limit
|
||||
3344038: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid role name
|
||||
3344039: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid role description
|
||||
3344040: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // unsupported member type
|
||||
3344041: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid member ID
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(sparkCodeMeta, "spark") }
|
||||
59
internal/errclass/codemeta_spark_test.go
Normal file
59
internal/errclass/codemeta_spark_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestLookupCodeMetaSparkRoleCodes(t *testing.T) {
|
||||
tests := []struct {
|
||||
code int
|
||||
category errs.Category
|
||||
subtype errs.Subtype
|
||||
}{
|
||||
{3340001, errs.CategoryAPI, errs.SubtypeInvalidParameters},
|
||||
{3344027, errs.CategoryAPI, errs.SubtypeQuotaExceeded},
|
||||
{3344028, errs.CategoryAPI, errs.SubtypeQuotaExceeded},
|
||||
{3344029, errs.CategoryAPI, errs.SubtypeQuotaExceeded},
|
||||
{3344030, errs.CategoryAuthorization, errs.SubtypePermissionDenied},
|
||||
{3344031, errs.CategoryAuthorization, errs.SubtypePermissionDenied},
|
||||
{3344034, errs.CategoryAPI, errs.SubtypeInvalidParameters},
|
||||
{3344035, errs.CategoryAPI, errs.SubtypeNotFound},
|
||||
{3344036, errs.CategoryAPI, errs.SubtypeAlreadyExists},
|
||||
{3344037, errs.CategoryAPI, errs.SubtypeQuotaExceeded},
|
||||
{3344038, errs.CategoryAPI, errs.SubtypeInvalidParameters},
|
||||
{3344039, errs.CategoryAPI, errs.SubtypeInvalidParameters},
|
||||
{3344040, errs.CategoryAPI, errs.SubtypeInvalidParameters},
|
||||
{3344041, errs.CategoryAPI, errs.SubtypeInvalidParameters},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(fmt.Sprintf("%d", tt.code), func(t *testing.T) {
|
||||
meta, ok := LookupCodeMeta(tt.code)
|
||||
if !ok {
|
||||
t.Fatalf("code %d is not registered", tt.code)
|
||||
}
|
||||
if meta.Category != tt.category || meta.Subtype != tt.subtype || meta.Retryable {
|
||||
t.Fatalf("code %d metadata = %+v, want category=%s subtype=%s retryable=false", tt.code, meta, tt.category, tt.subtype)
|
||||
}
|
||||
|
||||
err := BuildAPIError(map[string]any{
|
||||
"code": tt.code,
|
||||
"msg": "spark role error",
|
||||
"log_id": "log-spark-role",
|
||||
}, ClassifyContext{Identity: "user"})
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("BuildAPIError(%d) = %#v, want typed problem", tt.code, err)
|
||||
}
|
||||
if problem.Category != tt.category || problem.Subtype != tt.subtype || problem.Code != tt.code || problem.LogID != "log-spark-role" || problem.Retryable {
|
||||
t.Fatalf("BuildAPIError(%d) problem = %+v", tt.code, problem)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ package output
|
||||
type Envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Identity string `json:"identity,omitempty"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Meta *Meta `json:"meta,omitempty"`
|
||||
ContentSafetyAlert interface{} `json:"_content_safety_alert,omitempty"`
|
||||
|
||||
@@ -9,6 +9,7 @@ import "io"
|
||||
type SuccessEnvelopeOptions struct {
|
||||
CommandPath string
|
||||
Identity string
|
||||
DryRun bool
|
||||
JqExpr string
|
||||
Out io.Writer
|
||||
ErrOut io.Writer
|
||||
@@ -41,6 +42,7 @@ func WriteSuccessEnvelope(data interface{}, opts SuccessEnvelopeOptions) error {
|
||||
env := Envelope{
|
||||
OK: true,
|
||||
Identity: opts.Identity,
|
||||
DryRun: opts.DryRun,
|
||||
Data: data,
|
||||
Notice: GetNotice(),
|
||||
}
|
||||
|
||||
@@ -104,6 +104,47 @@ func TestWriteSuccessEnvelope_JqUsesEnvelope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSuccessEnvelope_DryRunMarker(t *testing.T) {
|
||||
var out strings.Builder
|
||||
|
||||
err := WriteSuccessEnvelope(map[string]interface{}{"api": []interface{}{}}, SuccessEnvelopeOptions{
|
||||
Identity: "bot",
|
||||
DryRun: true,
|
||||
Out: &out,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteSuccessEnvelope() error = %v", err)
|
||||
}
|
||||
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out.String()), &env); err != nil {
|
||||
t.Fatalf("invalid JSON output: %v\n%s", err, out.String())
|
||||
}
|
||||
if env["ok"] != true || env["identity"] != "bot" || env["dry_run"] != true {
|
||||
t.Fatalf("unexpected dry-run envelope: %#v", env)
|
||||
}
|
||||
if _, ok := env["data"].(map[string]interface{}); !ok {
|
||||
t.Fatalf("data = %#v, want object", env["data"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSuccessEnvelope_DryRunJqUsesEnvelope(t *testing.T) {
|
||||
var out strings.Builder
|
||||
|
||||
err := WriteSuccessEnvelope(map[string]interface{}{"api": []interface{}{}}, SuccessEnvelopeOptions{
|
||||
Identity: "bot",
|
||||
DryRun: true,
|
||||
JqExpr: ".dry_run",
|
||||
Out: &out,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteSuccessEnvelope() error = %v", err)
|
||||
}
|
||||
if strings.TrimSpace(out.String()) != "true" {
|
||||
t.Fatalf("jq output = %q, want true", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSuccessEnvelope_JqWarnsWhenSafetyAlertFiltered(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
||||
extcs.Register(&mockProvider{
|
||||
|
||||
@@ -337,7 +337,7 @@ func fakeValueFromPlaceholderName(name string) (string, bool) {
|
||||
case name == "open_id" || hasPlaceholderToken(tokens, "user", "owner", "participant", "approver", "speaker"):
|
||||
return "ou_test123", true
|
||||
case hasPlaceholderToken(tokens, "department", "dept"):
|
||||
return "od_test123", true
|
||||
return "od-test123", true
|
||||
case hasPlaceholderToken(tokens, "message"):
|
||||
return "om_test123", true
|
||||
case name == "file_key":
|
||||
@@ -878,16 +878,23 @@ func extractDryRunJSON(raw []byte) (facts.DryRunRequest, int, error) {
|
||||
var firstErr error
|
||||
for start >= 0 {
|
||||
var preview struct {
|
||||
API []facts.DryRunRequest `json:"api"`
|
||||
API []facts.DryRunRequest `json:"api"`
|
||||
Data struct {
|
||||
API []facts.DryRunRequest `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(raw[start:]))
|
||||
if err := dec.Decode(&preview); err == nil {
|
||||
if len(preview.API) == 0 {
|
||||
api := preview.API
|
||||
if len(api) == 0 {
|
||||
api = preview.Data.API
|
||||
}
|
||||
if len(api) == 0 {
|
||||
if firstErr == nil {
|
||||
firstErr = errNoDryRunAPI
|
||||
}
|
||||
} else {
|
||||
return preview.API[0], len(preview.API), nil
|
||||
return api[0], len(api), nil
|
||||
}
|
||||
} else if firstErr == nil {
|
||||
firstErr = err
|
||||
|
||||
@@ -33,6 +33,17 @@ func TestExtractDryRunJSONSkipsBanner(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDryRunJSONReadsSuccessEnvelope(t *testing.T) {
|
||||
raw := `{"ok":true,"dry_run":true,"data":{"api":[{"method":"GET","url":"/open-apis/test"}]}}`
|
||||
got, apiCallCount, err := extractDryRunJSON([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("extractDryRunJSON() error = %v", err)
|
||||
}
|
||||
if got.Method != "GET" || got.URL != "/open-apis/test" || apiCallCount != 1 {
|
||||
t.Fatalf("got request=%#v apiCallCount=%d, want enveloped GET and count 1", got, apiCallCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDryRunJSONSkipsBannerWithBraces(t *testing.T) {
|
||||
raw := "banner {not json}\n{\"api\":[{\"method\":\"GET\",\"url\":\"/open-apis/test\"}]}\n"
|
||||
got, apiCallCount, err := extractDryRunJSON([]byte(raw))
|
||||
@@ -305,6 +316,13 @@ func TestRunDryRunsMaterializesInlinePlaceholderFlagValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeValueFromPlaceholderNameUsesOpenDepartmentPrefix(t *testing.T) {
|
||||
got, ok := fakeValueFromPlaceholderName("open_department_id")
|
||||
if !ok || got != "od-test123" {
|
||||
t.Fatalf("open_department_id placeholder = %q, %v; want od-test123, true", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDryRunsMaterializesNumericPlaceholderFlagValues(t *testing.T) {
|
||||
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/vc/v1/bots/events","params":{"meeting_id":"400000000001","page_size":50}}]}`)
|
||||
m := manifest.Manifest{Commands: []manifest.Command{{
|
||||
|
||||
@@ -248,10 +248,18 @@ func TestLoadPlatformAutoApproveSet(t *testing.T) {
|
||||
|
||||
func TestLoadOverrideAutoApproveAllow(t *testing.T) {
|
||||
allowSet := LoadOverrideAutoApproveAllow()
|
||||
// recommend.allow in scope_overrides.json is intentionally empty:
|
||||
// no scopes are special-cased into the auto-approve set anymore.
|
||||
if len(allowSet) != 0 {
|
||||
t.Errorf("expected empty override allow set, got %d entries", len(allowSet))
|
||||
// recommend.allow special-cases scopes absent from scope_priorities.json
|
||||
// (application v7 is not in the platform catalog yet) so interactive
|
||||
// login's "common scopes" tier still offers them. Only the read scope is
|
||||
// admitted: write stays out of the recommended tier by design.
|
||||
if !allowSet["application:app_slash_command:read"] {
|
||||
t.Error("expected application:app_slash_command:read in override allow set")
|
||||
}
|
||||
if allowSet["application:app_slash_command:write"] {
|
||||
t.Error("write scope must NOT be in the recommended tier")
|
||||
}
|
||||
if len(allowSet) != 1 {
|
||||
t.Errorf("expected exactly 1 override allow entry, got %d", len(allowSet))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
"vc:meeting.meetingevent:read": 75
|
||||
},
|
||||
"recommend": {
|
||||
"allow": [],
|
||||
"allow": [
|
||||
"application:app_slash_command:read"
|
||||
],
|
||||
"deny": [
|
||||
"im:chat",
|
||||
"im:message.send_as_user"
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"en": { "title": "Approval", "description": "Approval instance, and task management" },
|
||||
"zh": { "title": "审批", "description": "审批实例、审批任务管理" }
|
||||
},
|
||||
"application": {
|
||||
"en": { "title": "Application", "description": "Open Platform app self-management: slash commands for the currently bound app" },
|
||||
"zh": { "title": "应用管理", "description": "开放平台应用自管理:当前绑定应用的斜杠指令管理" }
|
||||
},
|
||||
"apps": {
|
||||
"en": { "title": "Apps", "description": "Develop, deploy HTML, web pages and applications" },
|
||||
"zh": { "title": "应用", "description": "开发、部署 HTML、Web 页面和应用" }
|
||||
|
||||
@@ -65,7 +65,7 @@ func safePath(raw, flagName string) (string, error) {
|
||||
}
|
||||
|
||||
if isAbsolutePath(raw) {
|
||||
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw)
|
||||
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: use a relative path like ./filename; flags that support stdin can read an out-of-tree file via '-' instead)", flagName, raw)
|
||||
}
|
||||
|
||||
path := filepath.Clean(raw)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.68",
|
||||
"version": "1.0.71",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
18
shortcuts/application/shortcuts.go
Normal file
18
shortcuts/application/shortcuts.go
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package application provides shortcuts for Open Platform app
|
||||
// self-management (slash commands of the current bound app).
|
||||
package application
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
// Shortcuts returns all shortcuts of the application domain.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
SlashCommandList,
|
||||
SlashCommandCreate,
|
||||
SlashCommandUpdate,
|
||||
SlashCommandDelete,
|
||||
}
|
||||
}
|
||||
105
shortcuts/application/slash_command_common.go
Normal file
105
shortcuts/application/slash_command_common.go
Normal file
@@ -0,0 +1,105 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
// slashCommandBasePath is the raw v7 endpoint (not in meta_data.json / SDK).
|
||||
const slashCommandBasePath = "/open-apis/application/v7/app_slash_commands"
|
||||
|
||||
// clientCacheHint is printed to stderr after every successful write.
|
||||
const clientCacheHint = "note: changes take ~5 minutes to appear in Feishu clients (client-side cache); the server state is already updated - list reflects it immediately."
|
||||
|
||||
// parseDescriptionI18n parses repeated --description-i18n values ("<lang>=<text>",
|
||||
// split on the FIRST '='). Returns nil for empty input. Duplicate langs rejected.
|
||||
func parseDescriptionI18n(values []string) (map[string]string, error) {
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
m := make(map[string]string, len(values))
|
||||
for _, v := range values {
|
||||
idx := strings.Index(v, "=")
|
||||
if idx <= 0 || idx == len(v)-1 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid --description-i18n value %q: expected <lang>=<text> (e.g. zh_cn=你好)", v).
|
||||
WithParam("--description-i18n")
|
||||
}
|
||||
lang := strings.TrimSpace(v[:idx])
|
||||
text := v[idx+1:]
|
||||
if lang == "" || strings.TrimSpace(text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid --description-i18n value %q: language and text must be non-empty", v).
|
||||
WithParam("--description-i18n")
|
||||
}
|
||||
if _, dup := m[lang]; dup {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"duplicate language %q in --description-i18n", lang).
|
||||
WithParam("--description-i18n")
|
||||
}
|
||||
m[lang] = text
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// validateCommandName rejects empty and slash-prefixed command names.
|
||||
func validateCommandName(name, flagName string) error {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s must not be empty", flagName).WithParam(flagName)
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "/") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s must not start with \"/\" - the slash is implied (use %q)",
|
||||
flagName, strings.TrimPrefix(trimmed, "/")).WithParam(flagName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// encodeCommandIDPathSegment applies the same normalization and escaping to
|
||||
// command IDs in dry-run output and real requests.
|
||||
func encodeCommandIDPathSegment(id string) string {
|
||||
return validate.EncodePathSegment(strings.TrimSpace(id))
|
||||
}
|
||||
|
||||
// buildSlashCommandBody assembles a create/update request body. Only provided
|
||||
// fields are included: PATCH is field-level partial (absent top-level fields
|
||||
// are preserved server-side; a provided i18n map REPLACES the whole map).
|
||||
// icon sits at the top level, sibling of description (verified live; the
|
||||
// official create sample nesting icon inside description is a doc bug).
|
||||
func buildSlashCommandBody(command, description string, i18n map[string]string, iconKey string) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if command != "" {
|
||||
body["command"] = command
|
||||
}
|
||||
if description != "" || len(i18n) > 0 {
|
||||
desc := map[string]interface{}{}
|
||||
if description != "" {
|
||||
desc["default_value"] = description
|
||||
}
|
||||
if len(i18n) > 0 {
|
||||
desc["i18n"] = i18n
|
||||
}
|
||||
body["description"] = desc
|
||||
}
|
||||
if iconKey != "" {
|
||||
body["icon"] = map[string]interface{}{"icon_key": iconKey}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// isCommandExists reports whether err is the server-side name-collision error
|
||||
// (code=40000000, message contains "command already exists"; verified live).
|
||||
func isCommandExists(err error) bool {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return p.Code == 40000000 && strings.Contains(p.Message, "command already exists")
|
||||
}
|
||||
197
shortcuts/application/slash_command_common_test.go
Normal file
197
shortcuts/application/slash_command_common_test.go
Normal file
@@ -0,0 +1,197 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestParseDescriptionI18n_OK(t *testing.T) {
|
||||
m, err := parseDescriptionI18n([]string{"zh_cn=你好", "en_us=Hello=World"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if m["zh_cn"] != "你好" {
|
||||
t.Errorf("zh_cn = %q", m["zh_cn"])
|
||||
}
|
||||
// 只按首个 = 分割:值内可含 =
|
||||
if m["en_us"] != "Hello=World" {
|
||||
t.Errorf("en_us = %q", m["en_us"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDescriptionI18n_Empty(t *testing.T) {
|
||||
m, err := parseDescriptionI18n(nil)
|
||||
if err != nil || m != nil {
|
||||
t.Fatalf("nil input: m=%v err=%v", m, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDescriptionI18n_BadFormat(t *testing.T) {
|
||||
for _, bad := range []string{"zh_cn", "=text", "zh_cn=", " =x"} {
|
||||
_, err := parseDescriptionI18n([]string{bad})
|
||||
if err == nil {
|
||||
t.Errorf("%q: expected error", bad)
|
||||
continue
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("%q: expected validation problem, got %v", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDescriptionI18n_DuplicateLang(t *testing.T) {
|
||||
_, err := parseDescriptionI18n([]string{"zh_cn=a", "zh_cn=b"})
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate language error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected validation/invalid_argument, got %v", err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Param != "--description-i18n" {
|
||||
t.Fatalf("expected param --description-i18n, got %#v", validationErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCommandName(t *testing.T) {
|
||||
if err := validateCommandName("greet", "--command"); err != nil {
|
||||
t.Fatalf("greet: %v", err)
|
||||
}
|
||||
for _, bad := range []string{"", " ", "/greet"} {
|
||||
if err := validateCommandName(bad, "--command"); err == nil {
|
||||
t.Errorf("%q: expected error", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSlashCommandBody(t *testing.T) {
|
||||
body := buildSlashCommandBody("greet", "hi", map[string]string{"zh_cn": "你好"}, "skill_outlined")
|
||||
if body["command"] != "greet" {
|
||||
t.Errorf("command = %v", body["command"])
|
||||
}
|
||||
desc := body["description"].(map[string]interface{})
|
||||
if desc["default_value"] != "hi" {
|
||||
t.Errorf("default_value = %v", desc["default_value"])
|
||||
}
|
||||
if desc["i18n"].(map[string]string)["zh_cn"] != "你好" {
|
||||
t.Errorf("i18n = %v", desc["i18n"])
|
||||
}
|
||||
// icon 与 description 顶层平级(实测钉死,文档 create 示例是笔误)
|
||||
if body["icon"].(map[string]interface{})["icon_key"] != "skill_outlined" {
|
||||
t.Errorf("icon = %v", body["icon"])
|
||||
}
|
||||
// partial:不提供的字段不出现(PATCH 语义依赖)
|
||||
partial := buildSlashCommandBody("", "", nil, "skill_outlined")
|
||||
if _, has := partial["command"]; has {
|
||||
t.Error("empty command must be omitted")
|
||||
}
|
||||
if _, has := partial["description"]; has {
|
||||
t.Error("empty description must be omitted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCommandExists(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "matching code and message",
|
||||
err: errs.NewAPIError(errs.SubtypeUnknown,
|
||||
"Invalid Param 'command'. command already exists.").WithCode(40000000),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "same message with different code",
|
||||
err: errs.NewAPIError(errs.SubtypeUnknown,
|
||||
"Invalid Param 'command'. command already exists.").WithCode(40000031),
|
||||
},
|
||||
{
|
||||
name: "same code with different message",
|
||||
err: errs.NewAPIError(errs.SubtypeUnknown,
|
||||
"Invalid Param 'icon_key'. icon_key is invalid.").WithCode(40000000),
|
||||
},
|
||||
{name: "nil error"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isCommandExists(tt.err); got != tt.want {
|
||||
t.Fatalf("isCommandExists() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlashCommandShortcuts_SharedScopesAcrossIdentities locks in the
|
||||
// reversal of the OAuth-isolation design: all four slash-command shortcuts
|
||||
// declare identical scopes for the bot and user identities (plain Scopes /
|
||||
// ConditionalScopes, no per-identity overrides), so a user-identity
|
||||
// pre-flight sees the same scope set a bot identity would.
|
||||
func TestSlashCommandShortcuts_SharedScopesAcrossIdentities(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
wantScope string
|
||||
wantConditional string
|
||||
hasConditional bool
|
||||
}{
|
||||
{
|
||||
name: "list",
|
||||
shortcut: SlashCommandList,
|
||||
wantScope: "application:app_slash_command:read",
|
||||
},
|
||||
{
|
||||
name: "create",
|
||||
shortcut: SlashCommandCreate,
|
||||
wantScope: "application:app_slash_command:write",
|
||||
wantConditional: "application:app_slash_command:read",
|
||||
hasConditional: true,
|
||||
},
|
||||
{
|
||||
name: "update",
|
||||
shortcut: SlashCommandUpdate,
|
||||
wantScope: "application:app_slash_command:write",
|
||||
wantConditional: "application:app_slash_command:read",
|
||||
hasConditional: true,
|
||||
},
|
||||
{
|
||||
name: "delete",
|
||||
shortcut: SlashCommandDelete,
|
||||
wantScope: "application:app_slash_command:write",
|
||||
wantConditional: "application:app_slash_command:read",
|
||||
hasConditional: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for _, identity := range []string{"user", "bot"} {
|
||||
declared := tc.shortcut.DeclaredScopesForIdentity(identity)
|
||||
if !containsStr(declared, tc.wantScope) {
|
||||
t.Errorf("%s: DeclaredScopesForIdentity(%q) = %v, want to contain %q", tc.name, identity, declared, tc.wantScope)
|
||||
}
|
||||
if tc.hasConditional && !containsStr(declared, tc.wantConditional) {
|
||||
t.Errorf("%s: DeclaredScopesForIdentity(%q) = %v, want to contain conditional %q", tc.name, identity, declared, tc.wantConditional)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func containsStr(list []string, want string) bool {
|
||||
for _, v := range list {
|
||||
if v == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
118
shortcuts/application/slash_command_create.go
Normal file
118
shortcuts/application/slash_command_create.go
Normal file
@@ -0,0 +1,118 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlashCommandCreate registers a new slash command on the current bound app.
|
||||
var SlashCommandCreate = common.Shortcut{
|
||||
Service: "application",
|
||||
Command: "+slash-command-create",
|
||||
Description: "Register a slash command (/ command) on the current bound Open Platform app; --force converts a name collision into an update (idempotent re-run)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"application:app_slash_command:write"},
|
||||
ConditionalScopes: []string{
|
||||
"application:app_slash_command:read", // only the --force collision path lists to resolve the id
|
||||
},
|
||||
AuthTypes: []string{"bot", "user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "command", Desc: "command name WITHOUT the leading slash (server enforces uniqueness per app; max 100 commands)", Required: true},
|
||||
{Name: "description", Desc: "default description shown in the client command panel (description.default_value)", Required: true},
|
||||
{Name: "description-i18n", Type: "string_array", Desc: "localized description, repeatable, format <lang>=<text> (e.g. zh_cn=发送问候); language codes are passed through to the server"},
|
||||
{Name: "icon-key", Desc: "icon key (server default: skill_outlined; invalid keys are rejected server-side with code 40000031)"},
|
||||
{Name: "force", Type: "bool", Desc: "on name collision, resolve the existing command by name and update it in place"},
|
||||
},
|
||||
Tips: []string{
|
||||
`lark-cli application +slash-command-create --command greet --description "say hi" --description-i18n zh_cn=问候 --as bot`,
|
||||
"changes take ~5 minutes to appear in clients (client-side cache); the server updates immediately",
|
||||
"user identity needs explicit authorization first: lark-cli auth login --scope application:app_slash_command:write",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateCommandName(runtime.Str("command"), "--command"); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(strings.TrimSpace(runtime.Str("description"))) == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--description must not be blank").WithParam("--description")
|
||||
}
|
||||
if _, err := parseDescriptionI18n(runtime.StrArray("description-i18n")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
|
||||
if err != nil {
|
||||
// The CLI validates first; keep this guard for direct DryRun callers.
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
name := strings.TrimSpace(runtime.Str("command"))
|
||||
body := buildSlashCommandBody(name, runtime.Str("description"), i18n, runtime.Str("icon-key"))
|
||||
d := common.NewDryRunAPI().
|
||||
Desc("Create a slash command on the current bound app").
|
||||
POST(slashCommandBasePath).
|
||||
Body(body)
|
||||
if runtime.Bool("force") {
|
||||
d.Desc("--force: on 'command already exists' (code 40000000), GET list to resolve command_id then PATCH the same body")
|
||||
}
|
||||
return d
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
name := strings.TrimSpace(runtime.Str("command"))
|
||||
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := buildSlashCommandBody(name, runtime.Str("description"), i18n, runtime.Str("icon-key"))
|
||||
|
||||
data, err := runtime.CallAPITyped("POST", slashCommandBasePath, nil, body)
|
||||
action := "created"
|
||||
if err != nil {
|
||||
if !isCommandExists(err) {
|
||||
return err
|
||||
}
|
||||
if !runtime.Bool("force") {
|
||||
p, _ := errs.ProblemOf(err)
|
||||
rewrapped := errs.NewAPIError(errs.SubtypeAlreadyExists, "slash command %q already exists", name).
|
||||
WithHint("rerun with --force to update it, or use `lark-cli application +slash-command-update --command %q`", name).
|
||||
WithCause(err)
|
||||
if p.Code != 0 {
|
||||
rewrapped = rewrapped.WithCode(p.Code)
|
||||
}
|
||||
if p.LogID != "" {
|
||||
rewrapped = rewrapped.WithLogID(p.LogID)
|
||||
}
|
||||
return rewrapped
|
||||
}
|
||||
// --force: name collision -> resolve id -> PATCH (idempotent re-run).
|
||||
id, rerr := resolveCommandID(runtime, name)
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
patchBody := buildSlashCommandBody("", runtime.Str("description"), i18n, runtime.Str("icon-key"))
|
||||
data, err = runtime.CallAPITyped("PATCH", slashCommandBasePath+"/"+encodeCommandIDPathSegment(id), nil, patchBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
action = "updated"
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
data["action"] = action
|
||||
fmt.Fprintln(runtime.IO().ErrOut, clientCacheHint)
|
||||
runtime.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%s /%v (command_id: %v)\n", action, data["command"], data["command_id"])
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
229
shortcuts/application/slash_command_create_test.go
Normal file
229
shortcuts/application/slash_command_create_test.go
Normal file
@@ -0,0 +1,229 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func createOKStub() *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/application/v7/app_slash_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": sampleItem("greet", "id-new"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createConflictStub() *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/application/v7/app_slash_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 40000000, "msg": "Invalid Param 'command'. command already exists.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func patchOKStub(id string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/application/v7/app_slash_commands/" + id,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": sampleItem("greet", id),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandCreate_OK(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(createOKStub())
|
||||
|
||||
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
|
||||
"--command", "greet", "--description", "hi",
|
||||
"--description-i18n", "zh_cn=你好", "--description-i18n", "en_us=Hello",
|
||||
"--icon-key", "skill_outlined", "--format", "json", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
if data["action"] != "created" {
|
||||
t.Fatalf("action = %v", data["action"])
|
||||
}
|
||||
if data["command_id"] != "id-new" {
|
||||
t.Fatalf("command_id = %v", data["command_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandCreate_ValidateRejects(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
cases := [][]string{
|
||||
{"+slash-command-create", "--command", "/greet", "--description", "hi", "--as", "bot"},
|
||||
{"+slash-command-create", "--command", "greet", "--description", "hi", "--description-i18n", "bad", "--as", "bot"},
|
||||
{"+slash-command-create", "--command", "greet", "--description", "hi", "--description-i18n", "zh_cn=a", "--description-i18n", "zh_cn=b", "--as", "bot"},
|
||||
{"+slash-command-create", "--command", "greet", "--description", " ", "--as", "bot"},
|
||||
}
|
||||
for i, args := range cases {
|
||||
err := mountAndRun(t, SlashCommandCreate, args, f, stdout)
|
||||
if err == nil {
|
||||
t.Errorf("case %d: expected validation error", i)
|
||||
continue
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation {
|
||||
t.Errorf("case %d: expected validation problem, got %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandCreate_ConflictNoForce(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(createConflictStub())
|
||||
|
||||
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
|
||||
"--command", "greet", "--description", "hi", "--as", "bot"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected conflict error")
|
||||
}
|
||||
p, _ := errs.ProblemOf(err)
|
||||
if p == nil || p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeAlreadyExists || p.Code != 40000000 {
|
||||
t.Fatalf("expected api/already_exists code 40000000, got %#v", p)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "--force") || !strings.Contains(p.Hint, "+slash-command-update") {
|
||||
t.Fatalf("hint must offer --force and update, got %q", p.Hint)
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("rewrapped error must be *errs.APIError, got %T", err)
|
||||
}
|
||||
if errors.Unwrap(apiErr) == nil {
|
||||
t.Fatal("rewrapped conflict error must preserve the original cause via WithCause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandCreate_ForceConvertsToUpdate(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(createConflictStub())
|
||||
reg.Register(listStub([]interface{}{sampleItem("greet", "id-exist")}))
|
||||
reg.Register(patchOKStub("id-exist"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
|
||||
"--command", "greet", "--description", "hi2", "--force", "--format", "json", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
if data["action"] != "updated" {
|
||||
t.Fatalf("action = %v (force must convert to update)", data["action"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandCreate_TrimsCommandBeforeCreateAndForceResolution(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
conflict := createConflictStub()
|
||||
reg.Register(conflict)
|
||||
reg.Register(listStub([]interface{}{sampleItem("greet", "id-exist")}))
|
||||
reg.Register(patchOKStub("id-exist"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
|
||||
"--command", " greet ", "--description", "hi", "--force", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(conflict.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode captured create body: %v", err)
|
||||
}
|
||||
if body["command"] != "greet" {
|
||||
t.Fatalf("command = %q, want trimmed value %q", body["command"], "greet")
|
||||
}
|
||||
}
|
||||
|
||||
func createIconInvalidStub() *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/application/v7/app_slash_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 40000031, "msg": "Invalid Param 'icon_key'. icon_key is invalid.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlashCommandCreate_ForceDoesNotConvertNonConflict guards against --force
|
||||
// blindly treating ANY POST failure as a name collision: only the
|
||||
// "command already exists" (40000000) shape may fall through to the
|
||||
// GET+PATCH idempotent-update path. No PATCH stub is registered here, so if
|
||||
// the code mistakenly attempted a PATCH, the httpmock registry would fail
|
||||
// the unexpected request and surface a different (registry) error instead
|
||||
// of the original icon_key failure asserted below.
|
||||
func TestSlashCommandCreate_ForceDoesNotConvertNonConflict(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(createIconInvalidStub())
|
||||
|
||||
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
|
||||
"--command", "greet", "--description", "hi", "--icon-key", "bogus", "--force", "--as", "bot"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected the original icon_key error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryAPI || p.Subtype == errs.SubtypeAlreadyExists || p.Code != 40000031 {
|
||||
t.Fatalf("expected original API error code 40000031 without collision reclassification, got %#v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandCreate_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
if err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
|
||||
"--command", "greet", "--description", "hi", "--icon-key", "skill_outlined", "--dry-run", "--as", "bot"}, f, stdout); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "POST") || !strings.Contains(out, slashCommandBasePath) {
|
||||
t.Fatalf("dry-run must show POST path: %s", out)
|
||||
}
|
||||
// icon 顶层:dry-run body 里 icon 不嵌套在 description 内
|
||||
if !strings.Contains(out, "icon_key") {
|
||||
t.Fatalf("dry-run must include body: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandCreate_ForceHelpHasNoMetavar(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "application"}
|
||||
SlashCommandCreate.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
forceFlag := cmd.Flags().Lookup("force")
|
||||
if forceFlag == nil {
|
||||
t.Fatal("missing --force flag")
|
||||
}
|
||||
placeholder, usage := pflag.UnquoteUsage(forceFlag)
|
||||
if placeholder != "" {
|
||||
t.Fatalf("boolean --force must not render a value placeholder, got %q", placeholder)
|
||||
}
|
||||
if !strings.Contains(usage, "update it in place") || strings.Contains(usage, "gh ") {
|
||||
t.Fatalf("unexpected --force help: %q", usage)
|
||||
}
|
||||
if help := cmd.Flags().FlagUsages(); !strings.Contains(help, "--force") || !strings.Contains(help, "update it in place") {
|
||||
t.Fatalf("rendered help missing --force description:\n%s", help)
|
||||
}
|
||||
}
|
||||
85
shortcuts/application/slash_command_delete.go
Normal file
85
shortcuts/application/slash_command_delete.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlashCommandDelete removes a slash command (irreversible; command_id is not
|
||||
// reused - recreating the same name yields a NEW id).
|
||||
var SlashCommandDelete = common.Shortcut{
|
||||
Service: "application",
|
||||
Command: "+slash-command-delete",
|
||||
Description: "Delete a slash command from the current bound app (high-risk: irreversible; recreating the same name yields a new command_id)",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"application:app_slash_command:write"},
|
||||
ConditionalScopes: []string{
|
||||
"application:app_slash_command:read", // only the --command by-name path
|
||||
},
|
||||
AuthTypes: []string{"bot", "user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "command-id", Desc: "target command_id; mutually exclusive with --command"},
|
||||
{Name: "command", Desc: "target command name WITHOUT leading slash (resolved via live list, needs read scope); mutually exclusive with --command-id"},
|
||||
},
|
||||
Tips: []string{
|
||||
"lark-cli application +slash-command-delete --command greet --yes --as bot",
|
||||
"deleted commands may linger in clients for ~5 minutes (client cache)",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
id := strings.TrimSpace(runtime.Str("command-id"))
|
||||
name := strings.TrimSpace(runtime.Str("command"))
|
||||
if (id == "") == (name == "") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"provide exactly one of --command-id or --command").WithParam("--command-id")
|
||||
}
|
||||
if name != "" {
|
||||
return validateCommandName(name, "--command")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
d := common.NewDryRunAPI().Desc("HIGH-RISK: delete a slash command (irreversible; same-name recreate gets a NEW command_id)")
|
||||
target := strings.TrimSpace(runtime.Str("command-id"))
|
||||
if target == "" {
|
||||
name := strings.TrimSpace(runtime.Str("command"))
|
||||
d.GET(slashCommandBasePath).
|
||||
Desc(fmt.Sprintf("resolve command_id by name %q via GET list first", name))
|
||||
target = "<resolved_command_id>"
|
||||
} else {
|
||||
target = encodeCommandIDPathSegment(target)
|
||||
}
|
||||
return d.DELETE(slashCommandBasePath + "/" + target)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
id := strings.TrimSpace(runtime.Str("command-id"))
|
||||
name := strings.TrimSpace(runtime.Str("command"))
|
||||
if id == "" {
|
||||
resolved, err := resolveCommandID(runtime, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id = resolved
|
||||
}
|
||||
if _, err := runtime.CallAPITyped("DELETE", slashCommandBasePath+"/"+encodeCommandIDPathSegment(id), nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
out := map[string]interface{}{"action": "deleted", "command_id": id}
|
||||
if name != "" {
|
||||
out["command"] = name
|
||||
}
|
||||
fmt.Fprintln(runtime.IO().ErrOut, clientCacheHint)
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "note: recreating the same command name will yield a NEW command_id.")
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "deleted command_id %s\n", id)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
135
shortcuts/application/slash_command_delete_test.go
Normal file
135
shortcuts/application/slash_command_delete_test.go
Normal file
@@ -0,0 +1,135 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func deleteOKStub(id string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: slashCommandBasePath + "/" + id,
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandDelete_RequiresYes(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
|
||||
"--command-id", "id1", "--as", "bot"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected confirmation_required without --yes")
|
||||
}
|
||||
if errs.CategoryOf(err) != errs.CategoryConfirmation {
|
||||
t.Fatalf("expected confirmation category, got %v (%v)", errs.CategoryOf(err), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandDelete_ByIDWithYes(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(deleteOKStub("id1"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
|
||||
"--command-id", "id1", "--yes", "--format", "json", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
// 上游 DELETE 返回空对象;CLI 必须补 action/command_id(写操作返回资源 ID)
|
||||
if data["action"] != "deleted" || data["command_id"] != "id1" {
|
||||
t.Fatalf("data = %v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandDelete_ByNameWithYes(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(listStub([]interface{}{sampleItem("greet", "id7")}))
|
||||
reg.Register(deleteOKStub("id7"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
|
||||
"--command", "greet", "--yes", "--format", "json", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
if data["command"] != "greet" || data["command_id"] != "id7" {
|
||||
t.Fatalf("data = %v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandDelete_ByNameDryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
|
||||
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
|
||||
"--command", "greet", "--dry-run", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var envlp struct {
|
||||
Data struct {
|
||||
Description string `json:"description"`
|
||||
API []struct {
|
||||
Desc string `json:"desc"`
|
||||
Method string `json:"method"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envlp); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
got := envlp.Data
|
||||
if !strings.Contains(got.Description, "HIGH-RISK") || strings.Contains(got.Description, "resolve command_id") {
|
||||
t.Fatalf("top-level description must contain only the risk context: %q", got.Description)
|
||||
}
|
||||
if len(got.API) != 2 || got.API[0].Method != "GET" || !strings.Contains(got.API[0].Desc, "resolve command_id") {
|
||||
t.Fatalf("first call must describe name resolution: %#v", got.API)
|
||||
}
|
||||
if got.API[1].Method != "DELETE" || strings.Contains(got.API[1].Desc, "resolve command_id") {
|
||||
t.Fatalf("second call must be the delete without the resolve description: %#v", got.API)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandDelete_Validate(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
for _, args := range [][]string{
|
||||
{"+slash-command-delete", "--yes", "--as", "bot"},
|
||||
{"+slash-command-delete", "--command-id", "id1", "--command", "greet", "--yes", "--as", "bot"},
|
||||
} {
|
||||
err := mountAndRun(t, SlashCommandDelete, args, f, stdout)
|
||||
if err == nil {
|
||||
t.Errorf("%v: expected validation error", args)
|
||||
continue
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("%v: expected validation problem, got %v", args, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandDelete_ByIDEncodesTrimmedPathSegment(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(deleteOKStub("id%2Fwith%20space%3Fx"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
|
||||
"--command-id", " id/with space?x ", "--yes", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
}
|
||||
58
shortcuts/application/slash_command_list.go
Normal file
58
shortcuts/application/slash_command_list.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlashCommandList lists all slash commands of the current bound app.
|
||||
var SlashCommandList = common.Shortcut{
|
||||
Service: "application",
|
||||
Command: "+slash-command-list",
|
||||
Description: "List all slash commands (/ commands) registered on the currently bound Open Platform app; source of command_id for update/delete",
|
||||
Risk: "read",
|
||||
Scopes: []string{"application:app_slash_command:read"},
|
||||
AuthTypes: []string{"bot", "user"},
|
||||
Tips: []string{
|
||||
"lark-cli application +slash-command-list --as bot",
|
||||
"user identity needs explicit authorization first: lark-cli auth login --scope application:app_slash_command:read",
|
||||
"the upstream API returns all commands at once (max 100 per app, no pagination)",
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("List all slash commands of the current bound app (read-only)").
|
||||
GET(slashCommandBasePath)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
data, err := runtime.CallAPITyped("GET", slashCommandBasePath, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
if items == nil {
|
||||
items = []interface{}{}
|
||||
}
|
||||
out := map[string]interface{}{"items": items, "count": len(items)}
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%d slash command(s)\n", len(items))
|
||||
for _, it := range items {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
desc := ""
|
||||
if d, ok := m["description"].(map[string]interface{}); ok {
|
||||
desc, _ = d["default_value"].(string)
|
||||
}
|
||||
fmt.Fprintf(w, " /%v\t%v\t%s\n", m["command"], m["command_id"], desc)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
115
shortcuts/application/slash_command_list_test.go
Normal file
115
shortcuts/application/slash_command_list_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func appTestConfig() *core.CliConfig {
|
||||
return &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
|
||||
}
|
||||
|
||||
// mountAndRun mounts the shortcut under a parent cobra command and runs it.
|
||||
// Mirrors shortcuts/contact tests.
|
||||
func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "application"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
func listStub(items []interface{}) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/application/v7/app_slash_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"items": items},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func sampleItem(name, id string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"command": name, "command_id": id,
|
||||
"create_time": "1783318553", "update_time": "1783318553",
|
||||
"description": map[string]interface{}{"default_value": "desc of " + name},
|
||||
"icon": map[string]interface{}{"icon_key": "skill_outlined"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandList_JSON(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(listStub([]interface{}{sampleItem("greet", "id1"), sampleItem("weather", "id2")}))
|
||||
|
||||
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--format", "json", "--as", "bot"}, f, stdout); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
items := data["items"].([]interface{})
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("items = %d", len(items))
|
||||
}
|
||||
if data["count"] != float64(2) {
|
||||
t.Fatalf("count = %v", data["count"])
|
||||
}
|
||||
first := items[0].(map[string]interface{})
|
||||
for _, k := range []string{"command", "command_id", "description", "icon", "create_time", "update_time"} {
|
||||
if _, ok := first[k]; !ok {
|
||||
t.Errorf("missing item key %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandList_Empty(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(listStub(nil))
|
||||
|
||||
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--format", "json", "--as", "bot"}, f, stdout); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
items, ok := data["items"].([]interface{})
|
||||
if !ok || len(items) != 0 {
|
||||
t.Fatalf("empty list must be [] not %v", data["items"])
|
||||
}
|
||||
if data["count"] != float64(0) {
|
||||
t.Fatalf("count = %v", data["count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandList_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--dry-run", "--as", "bot"}, f, stdout); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "/open-apis/application/v7/app_slash_commands") || !strings.Contains(out, "GET") {
|
||||
t.Fatalf("dry-run must show GET path, got %s", out)
|
||||
}
|
||||
}
|
||||
54
shortcuts/application/slash_command_resolve.go
Normal file
54
shortcuts/application/slash_command_resolve.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// matchCommandID finds the command_id of the item whose "command" equals
|
||||
// name (exact match - the server enforces name uniqueness, so first hit is the
|
||||
// only hit).
|
||||
func matchCommandID(items []interface{}, name string) string {
|
||||
for _, it := range items {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if m["command"] == name {
|
||||
id, _ := m["command_id"].(string)
|
||||
if id != "" {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// commandNotFoundError reports a resolution miss against the live list as an
|
||||
// API-category not-found error (the name is a valid argument shape; the
|
||||
// resource simply does not exist server-side - this is not a validation
|
||||
// failure of caller input).
|
||||
func commandNotFoundError(name string) error {
|
||||
return errs.NewAPIError(errs.SubtypeNotFound,
|
||||
"slash command %q not found in the current bound app", name).
|
||||
WithHint("run `lark-cli application +slash-command-list` to see registered commands")
|
||||
}
|
||||
|
||||
// resolveCommandID resolves a command name to its command_id via the live
|
||||
// list endpoint (in-memory only; never touches local files). Requires the
|
||||
// read scope on the current identity.
|
||||
func resolveCommandID(runtime *common.RuntimeContext, name string) (string, error) {
|
||||
data, err := runtime.CallAPITyped("GET", slashCommandBasePath, nil, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
id := matchCommandID(items, name)
|
||||
if id == "" {
|
||||
return "", commandNotFoundError(name)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
41
shortcuts/application/slash_command_resolve_test.go
Normal file
41
shortcuts/application/slash_command_resolve_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestMatchCommandID(t *testing.T) {
|
||||
items := []interface{}{
|
||||
sampleItem("greet", "id1"),
|
||||
sampleItem("weather", "id2"),
|
||||
}
|
||||
id := matchCommandID(items, "weather")
|
||||
if id != "id2" {
|
||||
t.Fatalf("got id=%q", id)
|
||||
}
|
||||
id = matchCommandID(items, "nope")
|
||||
if id != "" {
|
||||
t.Fatalf("miss should return empty, got id=%q", id)
|
||||
}
|
||||
// 精确匹配:大小写与空白不做宽容
|
||||
id = matchCommandID(items, "Greet")
|
||||
if id != "" {
|
||||
t.Fatalf("match must be exact, got %q", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNotFoundErrorShape(t *testing.T) {
|
||||
err := commandNotFoundError("nope")
|
||||
if err == nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeNotFound {
|
||||
t.Fatalf("expected api/not_found, got %#v", p)
|
||||
}
|
||||
}
|
||||
124
shortcuts/application/slash_command_update.go
Normal file
124
shortcuts/application/slash_command_update.go
Normal file
@@ -0,0 +1,124 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// validateUpdateTarget enforces: exactly one of --command-id/--command, and at
|
||||
// least one editable field; --description-i18n requires --description (PATCH
|
||||
// replaces the whole description object - sending i18n alone would drop
|
||||
// default_value, so both values must be provided together).
|
||||
func validateUpdateTarget(runtime *common.RuntimeContext) error {
|
||||
id := strings.TrimSpace(runtime.Str("command-id"))
|
||||
name := strings.TrimSpace(runtime.Str("command"))
|
||||
if (id == "") == (name == "") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"provide exactly one of --command-id or --command").WithParam("--command-id")
|
||||
}
|
||||
if name != "" {
|
||||
if err := validateCommandName(name, "--command"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
hasDesc := strings.TrimSpace(runtime.Str("description")) != ""
|
||||
hasI18n := len(runtime.StrArray("description-i18n")) > 0
|
||||
hasIcon := strings.TrimSpace(runtime.Str("icon-key")) != ""
|
||||
if !hasDesc && !hasI18n && !hasIcon {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"provide at least one of --description / --description-i18n / --icon-key").WithParam("--description")
|
||||
}
|
||||
if hasI18n && !hasDesc {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--description-i18n requires --description: PATCH replaces the whole description object, so default_value must be provided together").WithParam("--description-i18n")
|
||||
}
|
||||
if _, err := parseDescriptionI18n(runtime.StrArray("description-i18n")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SlashCommandUpdate updates description/i18n/icon of an existing slash command.
|
||||
var SlashCommandUpdate = common.Shortcut{
|
||||
Service: "application",
|
||||
Command: "+slash-command-update",
|
||||
Description: "Update description / localized descriptions / icon of a slash command on the current bound app, addressed by --command-id or by name via --command",
|
||||
Risk: "write",
|
||||
Scopes: []string{"application:app_slash_command:write"},
|
||||
ConditionalScopes: []string{
|
||||
"application:app_slash_command:read", // only the --command by-name path lists to resolve the id
|
||||
},
|
||||
AuthTypes: []string{"bot", "user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "command-id", Desc: "target command_id (from +slash-command-list or create output); mutually exclusive with --command"},
|
||||
{Name: "command", Desc: "target command name WITHOUT leading slash; resolved via live list (needs read scope); mutually exclusive with --command-id"},
|
||||
{Name: "description", Desc: "new default description (description.default_value)"},
|
||||
{Name: "description-i18n", Type: "string_array", Desc: "localized description, repeatable <lang>=<text>; REPLACES the whole i18n map (missing languages are dropped); requires --description"},
|
||||
{Name: "icon-key", Desc: "new icon key (invalid keys rejected server-side with code 40000031)"},
|
||||
},
|
||||
Tips: []string{
|
||||
`lark-cli application +slash-command-update --command greet --description "new text" --as bot`,
|
||||
"PATCH is field-level partial: fields you do not pass are preserved server-side",
|
||||
"the command NAME itself cannot be changed (API limitation): rename = delete + create (new command_id)",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateUpdateTarget(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
|
||||
if err != nil {
|
||||
// The CLI validates first; keep this guard for direct DryRun callers.
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
body := buildSlashCommandBody("", runtime.Str("description"), i18n, runtime.Str("icon-key"))
|
||||
d := common.NewDryRunAPI()
|
||||
target := strings.TrimSpace(runtime.Str("command-id"))
|
||||
if target == "" {
|
||||
name := strings.TrimSpace(runtime.Str("command"))
|
||||
d.GET(slashCommandBasePath).
|
||||
Desc(fmt.Sprintf("resolve command_id by name %q via GET list first", name))
|
||||
target = "<resolved_command_id>"
|
||||
} else {
|
||||
target = encodeCommandIDPathSegment(target)
|
||||
}
|
||||
return d.PATCH(slashCommandBasePath + "/" + target).
|
||||
Desc("Update a slash command by command_id").
|
||||
Body(body)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
id := strings.TrimSpace(runtime.Str("command-id"))
|
||||
if id == "" {
|
||||
resolved, err := resolveCommandID(runtime, strings.TrimSpace(runtime.Str("command")))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id = resolved
|
||||
}
|
||||
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := buildSlashCommandBody("", runtime.Str("description"), i18n, runtime.Str("icon-key"))
|
||||
data, err := runtime.CallAPITyped("PATCH", slashCommandBasePath+"/"+encodeCommandIDPathSegment(id), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
data["action"] = "updated"
|
||||
fmt.Fprintln(runtime.IO().ErrOut, clientCacheHint)
|
||||
runtime.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "updated /%v (command_id: %v)\n", data["command"], data["command_id"])
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
150
shortcuts/application/slash_command_update_test.go
Normal file
150
shortcuts/application/slash_command_update_test.go
Normal file
@@ -0,0 +1,150 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
func TestSlashCommandUpdate_ByID(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(patchOKStub("id1"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
|
||||
"--command-id", "id1", "--description", "new", "--format", "json", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
if data["action"] != "updated" {
|
||||
t.Fatalf("action = %v", data["action"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandUpdate_ByName(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(listStub([]interface{}{sampleItem("greet", "id9")}))
|
||||
reg.Register(patchOKStub("id9"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
|
||||
"--command", "greet", "--icon-key", "skill_outlined", "--format", "json", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandUpdate_ByNameNotFound(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(listStub(nil))
|
||||
|
||||
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
|
||||
"--command", "nope", "--description", "x", "--as", "bot"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected not-found error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeNotFound {
|
||||
t.Fatalf("expected api/not_found, got %#v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandUpdate_Validate(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"both id and name", []string{"+slash-command-update", "--command-id", "id1", "--command", "greet", "--description", "x", "--as", "bot"}},
|
||||
{"neither id nor name", []string{"+slash-command-update", "--description", "x", "--as", "bot"}},
|
||||
{"no editable field", []string{"+slash-command-update", "--command-id", "id1", "--as", "bot"}},
|
||||
{"i18n without description", []string{"+slash-command-update", "--command-id", "id1", "--description-i18n", "zh_cn=x", "--as", "bot"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
err := mountAndRun(t, SlashCommandUpdate, c.args, f, stdout)
|
||||
if err == nil {
|
||||
t.Errorf("%s: expected validation error", c.name)
|
||||
continue
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("%s: expected validation problem, got %v", c.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandUpdate_ByIDEncodesTrimmedPathSegment(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(patchOKStub("id%2Fwith%20space%3Fx"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
|
||||
"--command-id", " id/with space?x ", "--description", "new", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandUpdate_ByNameDryRunDescriptions(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
|
||||
"--command", " greet ", "--description", "new", "--dry-run", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var envlp struct {
|
||||
Data struct {
|
||||
Description string `json:"description"`
|
||||
API []struct {
|
||||
Desc string `json:"desc"`
|
||||
Method string `json:"method"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envlp); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
got := envlp.Data
|
||||
if strings.Contains(got.Description, "resolve command_id") {
|
||||
t.Fatalf("resolve description must be attached to GET, not top-level: %q", got.Description)
|
||||
}
|
||||
if len(got.API) != 2 || got.API[0].Method != "GET" || !strings.Contains(got.API[0].Desc, "resolve command_id") {
|
||||
t.Fatalf("first call must describe name resolution: %#v", got.API)
|
||||
}
|
||||
if got.API[1].Method != "PATCH" || !strings.Contains(got.API[1].Desc, "Update a slash command") {
|
||||
t.Fatalf("second call must describe update: %#v", got.API)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandUpdate_ByIDDryRunEncodesTrimmedPathSegment(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
|
||||
"--command-id", " id/with space?x ", "--description", "new", "--dry-run", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var envlp struct {
|
||||
Data struct {
|
||||
API []struct {
|
||||
Desc string `json:"desc"`
|
||||
URL string `json:"url"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envlp); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
got := envlp.Data
|
||||
wantURL := slashCommandBasePath + "/id%2Fwith%20space%3Fx"
|
||||
if len(got.API) != 1 || got.API[0].URL != wantURL || got.API[0].Desc == "" {
|
||||
t.Fatalf("dry-run call = %#v, want encoded URL %q with description", got.API, wantURL)
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ func TestAppsAccessScopeGet_Specific(t *testing.T) {
|
||||
"data": map[string]interface{}{
|
||||
"scope": "Range",
|
||||
"users": []interface{}{"ou_x", "ou_y"},
|
||||
"departments": []interface{}{"od_z"},
|
||||
"departments": []interface{}{"od-z"},
|
||||
"chats": []interface{}{"oc_g"},
|
||||
"apply_config": map[string]interface{}{
|
||||
"enabled": true,
|
||||
@@ -39,7 +39,7 @@ func TestAppsAccessScopeGet_Specific(t *testing.T) {
|
||||
if !strings.Contains(got, `"scope": "Range"`) {
|
||||
t.Fatalf("scope string not preserved (expect raw \"Range\"): %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"ou_x"`) || !strings.Contains(got, `"od_z"`) || !strings.Contains(got, `"oc_g"`) {
|
||||
if !strings.Contains(got, `"ou_x"`) || !strings.Contains(got, `"od-z"`) || !strings.Contains(got, `"oc_g"`) {
|
||||
t.Fatalf("users/departments/chats fields missing in envelope: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"ou_appr"`) {
|
||||
|
||||
@@ -23,19 +23,21 @@ func TestAppsAnalyticsList_DryRunUsesNanoseconds(t *testing.T) {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
Data struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/query_analytics_data" {
|
||||
t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL)
|
||||
if env.Data.API[0].Method != "POST" || env.Data.API[0].URL != "/open-apis/spark/v1/apps/app_x/query_analytics_data" {
|
||||
t.Fatalf("method/url = %s %s", env.Data.API[0].Method, env.Data.API[0].URL)
|
||||
}
|
||||
body := env.API[0].Body
|
||||
body := env.Data.API[0].Body
|
||||
if _, ok := body["start_timestamp_ns"]; !ok {
|
||||
t.Fatalf("analytics dry-run missing start_timestamp_ns: %#v", body)
|
||||
}
|
||||
@@ -92,14 +94,16 @@ func TestAppsAnalyticsList_PageViewDesktopSeriesSetsDeviceFilter(t *testing.T) {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
Data struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
filter := env.API[0].Body["filter"].(map[string]interface{})
|
||||
filter := env.Data.API[0].Body["filter"].(map[string]interface{})
|
||||
deviceTypes := filter["device_types"].([]interface{})
|
||||
if len(deviceTypes) != 1 || deviceTypes[0] != "desktop" {
|
||||
t.Fatalf("device_types = %#v", deviceTypes)
|
||||
|
||||
253
shortcuts/apps/apps_automation_create.go
Normal file
253
shortcuts/apps/apps_automation_create.go
Normal file
@@ -0,0 +1,253 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsAutomationCreate creates an automation trigger (type-dispatched condition).
|
||||
var AppsAutomationCreate = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+automation-create",
|
||||
Description: "Create an automation trigger (cron/record-change/webhook/feishu-approval); created disabled",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +automation-create --app-id <id> --name daily --trigger-type cron --cron '0 9 * * *'",
|
||||
"Example: lark-cli apps +automation-create --app-id <id> --name onUpd --trigger-type record-change --table <tbl> --event UPDATE",
|
||||
"Example: lark-cli apps +automation-create --app-id <id> --name hook --trigger-type webhook",
|
||||
"Example: lark-cli apps +automation-create --app-id <id> --name apv --trigger-type feishu-approval --event-type approval_instance --instance-status APPROVED",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "name", Desc: "trigger name (unique within app, <=100 chars)", Required: true},
|
||||
{Name: "trigger-type", Desc: "cron | record-change | webhook | feishu-approval", Required: true},
|
||||
{Name: "description", Desc: "optional description (<=50 chars)"},
|
||||
{Name: "cron", Desc: "[cron] 5-field cron expression, e.g. '0 9 * * *' (min interval 30m)"},
|
||||
{Name: "timezone", Desc: "[cron] IANA timezone (default Asia/Shanghai)"},
|
||||
{Name: "table", Desc: "[record-change] table name (from `+db-table-list`); dataloom tables key by name, not id"},
|
||||
{Name: "event", Desc: "[record-change] INSERT | UPDATE | UPSERT | DELETE"},
|
||||
{Name: "fields", Desc: "[record-change] JSON array of field ids for UPDATE/UPSERT, [\"*\"] = all"},
|
||||
{Name: "white-ip-list", Desc: "[webhook] JSON array of allowed IPs"},
|
||||
{Name: "approval-code", Desc: "[feishu-approval] approval definition code; omit to match all approval definitions"},
|
||||
{Name: "event-type", Desc: "[feishu-approval] approval_instance | approval_task"},
|
||||
{Name: "instance-status", Type: "string_array", Desc: "[feishu-approval] statuses for approval_instance"},
|
||||
{Name: "task-status", Type: "string_array", Desc: "[feishu-approval] statuses for approval_task"},
|
||||
{Name: "status", Desc: "optional initial status: enabled | disabled (default disabled; backend supports create+enable in one call)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("name")) == "" {
|
||||
return appsValidationParamError("--name", "--name is required")
|
||||
}
|
||||
cliType := strings.TrimSpace(rctx.Str("trigger-type"))
|
||||
if cliType == "" {
|
||||
return appsValidationParamError("--trigger-type", "--trigger-type is required (cron/record-change/webhook/feishu-approval)")
|
||||
}
|
||||
// mapTriggerType also runs inside buildAutomationCreateBody, but
|
||||
// re-running it up-front keeps the cross-family guard's error
|
||||
// reachable — otherwise an unknown --trigger-type would bail out
|
||||
// with the same guard's "belongs to trigger-type" wording, which
|
||||
// misleads callers who typoed the type itself.
|
||||
if _, err := mapTriggerType(cliType); err != nil {
|
||||
return err
|
||||
}
|
||||
// Reject condition flags that do not belong to the selected type.
|
||||
// buildAutomationCreateBody's switch used to silently drop them
|
||||
// (e.g. --trigger-type webhook --cron '0 9 * * *' created a webhook
|
||||
// with no cron, though the caller believed --cron was set).
|
||||
if err := rejectCrossFamilyCondFlags(rctx, cliType); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := buildAutomationCreateBody(rctx)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
body, _ := buildAutomationCreateBody(rctx)
|
||||
return common.NewDryRunAPI().
|
||||
POST(automationListPath(appID)).
|
||||
Desc("Create automation trigger").
|
||||
Body(body)
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := buildAutomationCreateBody(rctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("POST", automationListPath(appID), nil, body)
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
// Bearer-token redaction reverse invariant: the backend create path
|
||||
// re-reads the freshly created trigger through the same read-path
|
||||
// converter used by get/list — theoretically capable of returning a
|
||||
// plaintext bearer token. On a fresh create the token is not yet
|
||||
// enabled and this response should not carry plaintext, but redact
|
||||
// for defense-in-depth and to keep every read-shaped output path
|
||||
// (create / get / list / update-patch) consistently scrubbed.
|
||||
redacted := redactWebhookToken(data)
|
||||
trigger, _ := redacted["trigger"].(map[string]interface{})
|
||||
rctx.OutFormat(redacted, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "created trigger: %v [%v] status: %v\n",
|
||||
trigger["name"], trigger["trigger_type"], trigger["status"])
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildAutomationCreateBody assembles {name, description?, trigger_type, <type>_condition}.
|
||||
func buildAutomationCreateBody(rctx *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
cliType := strings.TrimSpace(rctx.Str("trigger-type"))
|
||||
snake, err := mapTriggerType(cliType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := strings.TrimSpace(rctx.Str("name"))
|
||||
if err := validateAutomationNameLen(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"name": name,
|
||||
"trigger_type": snake,
|
||||
}
|
||||
if d := strings.TrimSpace(rctx.Str("description")); d != "" {
|
||||
if err := validateAutomationDescriptionLen(d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body["description"] = d
|
||||
}
|
||||
// --status is an optional passthrough: when set, backend creates + enables
|
||||
// (or leaves disabled) in one call. Omitting the field lets the backend
|
||||
// default (disabled) apply, matching the spec's default-disabled invariant.
|
||||
if s := strings.TrimSpace(rctx.Str("status")); s != "" {
|
||||
if s != "enabled" && s != "disabled" {
|
||||
return nil, appsValidationParamError("--status",
|
||||
"--status must be enabled or disabled, got %q", s)
|
||||
}
|
||||
body["status"] = s
|
||||
}
|
||||
switch cliType {
|
||||
case "cron":
|
||||
cond, err := buildCronCondition(rctx.Str("cron"), rctx.Str("timezone"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body["cron_condition"] = cond
|
||||
case "record-change":
|
||||
fields, err := parseFieldsFlag(rctx.Str("fields"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cond, err := buildRecordChangeCondition(rctx.Str("table"), rctx.Str("event"), fields)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body["record_change_condition"] = cond
|
||||
case "webhook":
|
||||
ipList, err := parseIPListFlag(rctx.Str("white-ip-list"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body["webhook_condition"] = buildWebhookCondition(ipList)
|
||||
case "feishu-approval":
|
||||
eventType := strings.TrimSpace(rctx.Str("event-type"))
|
||||
if eventType == "" {
|
||||
return nil, appsValidationParamError("--event-type", "--event-type is required for feishu-approval (approval_instance/approval_task)")
|
||||
}
|
||||
raw := rctx.StrArray("instance-status")
|
||||
if eventType == "approval_task" {
|
||||
raw = rctx.StrArray("task-status")
|
||||
}
|
||||
// buildApprovalCondition stores the passed statuses verbatim (it only
|
||||
// uppercases for validation), so normalize to the uppercase enum here to
|
||||
// guarantee the backend receives canonical values (foundation review).
|
||||
statuses := normalizeApprovalStatuses(raw)
|
||||
cond, err := buildApprovalCondition(rctx.Str("approval-code"), eventType, statuses)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body["feishu_approval_condition"] = cond
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// normalizeApprovalStatuses trims and uppercases each status so the body carries
|
||||
// the canonical enum values expected by the backend.
|
||||
func normalizeApprovalStatuses(raw []string) []string {
|
||||
if len(raw) == 0 {
|
||||
return raw
|
||||
}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, s := range raw {
|
||||
out = append(out, strings.ToUpper(strings.TrimSpace(s)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseFieldsFlag parses --fields JSON array; empty → nil.
|
||||
func parseFieldsFlag(raw string) ([]string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var arr []string
|
||||
if err := json.Unmarshal([]byte(raw), &arr); err != nil {
|
||||
return nil, appsValidationParamError("--fields", "--fields must be a JSON array of strings: %v", err)
|
||||
}
|
||||
return arr, nil
|
||||
}
|
||||
|
||||
// parseIPListFlag parses --white-ip-list JSON array; empty → nil (field
|
||||
// omitted). Each entry is validated as an IPv4/IPv6 address or CIDR, matching
|
||||
// the defense-in-depth stance the record-change --event whitelist takes —
|
||||
// silent acceptance of malformed IPs would let a typoed entry (`"1.1.1.1 "`
|
||||
// with trailing space, `"not-an-ip"`, or `"10.0.0.256"`) narrow the webhook
|
||||
// caller allowlist to nothing while the operator believes it is enforcing
|
||||
// origin restrictions.
|
||||
func parseIPListFlag(raw string) ([]string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var arr []string
|
||||
if err := json.Unmarshal([]byte(raw), &arr); err != nil {
|
||||
return nil, appsValidationParamError("--white-ip-list", "--white-ip-list must be a JSON array of strings: %v", err)
|
||||
}
|
||||
out := make([]string, 0, len(arr))
|
||||
for i, entry := range arr {
|
||||
trimmed := strings.TrimSpace(entry)
|
||||
if trimmed == "" {
|
||||
return nil, appsValidationParamError("--white-ip-list",
|
||||
"--white-ip-list entry %d is empty; either drop it or provide a valid IP/CIDR", i)
|
||||
}
|
||||
if net.ParseIP(trimmed) != nil {
|
||||
out = append(out, trimmed)
|
||||
continue
|
||||
}
|
||||
if _, _, cidrErr := net.ParseCIDR(trimmed); cidrErr == nil {
|
||||
out = append(out, trimmed)
|
||||
continue
|
||||
}
|
||||
return nil, appsValidationParamError("--white-ip-list",
|
||||
"--white-ip-list entry %d %q is not a valid IPv4/IPv6 address or CIDR block", i, entry)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
265
shortcuts/apps/apps_automation_create_test.go
Normal file
265
shortcuts/apps/apps_automation_create_test.go
Normal file
@@ -0,0 +1,265 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func automationCreateFlagDefs() map[string]string {
|
||||
return map[string]string{
|
||||
"app-id": "string", "name": "string", "trigger-type": "string", "description": "string",
|
||||
"cron": "string", "timezone": "string",
|
||||
"table": "string", "event": "string", "fields": "string",
|
||||
"white-ip-list": "string",
|
||||
"approval-code": "string", "event-type": "string",
|
||||
"instance-status": "string_array", "task-status": "string_array",
|
||||
"status": "string",
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationCreateCron_BuildsBody(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "daily", "trigger-type": "cron", "cron": "0 9 * * *"})
|
||||
// Real backend response wraps the created trigger under `trigger` (a live
|
||||
// test-env probe confirmed the shape, same as GET/PUT). The Execute pretty
|
||||
// path reads trigger["name"]/["trigger_type"]/["status"] from that key —
|
||||
// a flat fixture makes the pretty path print `<nil>` and only passes via
|
||||
// the JSON envelope, which hides regressions in the pretty branch.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/triggers",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"trigger": map[string]interface{}{
|
||||
"name": "daily", "trigger_type": "cron", "status": "disabled",
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err := AppsAutomationCreate.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdoutBuf.String(), "daily") {
|
||||
t.Errorf("create output must contain trigger name: %s", stdoutBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationCreate_MissingType(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "n"})
|
||||
err := AppsAutomationCreate.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, "--trigger-type")
|
||||
}
|
||||
|
||||
// TestAutomationCreate_CrossFamilyFlagsRejected pins the F1 guard: a condition
|
||||
// flag from a family other than --trigger-type used to be silently dropped by
|
||||
// buildAutomationCreateBody's single-branch switch, so
|
||||
// `--trigger-type webhook --cron '0 9 * * *'` created a webhook with no cron
|
||||
// but returned success. Validate now rejects the cross-family flag up-front.
|
||||
func TestAutomationCreate_CrossFamilyFlagsRejected(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
wantParam string
|
||||
}{
|
||||
{"webhook_with_cron",
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "n", "trigger-type": "webhook",
|
||||
"cron": "0 9 * * *",
|
||||
}, "--cron"},
|
||||
{"cron_with_white_ip_list",
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "n", "trigger-type": "cron",
|
||||
"cron": "0 9 * * *", "white-ip-list": `["1.1.1.1"]`,
|
||||
}, "--white-ip-list"},
|
||||
{"record_change_with_event_type",
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "n", "trigger-type": "record-change",
|
||||
"table": "tbl", "event": "UPDATE", "event-type": "approval_instance",
|
||||
}, "--event-type"},
|
||||
{"feishu_approval_with_table",
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "n", "trigger-type": "feishu-approval",
|
||||
"event-type": "approval_instance", "instance-status": "APPROVED",
|
||||
"table": "tbl",
|
||||
}, "--table"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(), tc.flags)
|
||||
err := AppsAutomationCreate.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, tc.wantParam)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationCreate_UnknownTriggerTypeRejected: --trigger-type must be one
|
||||
// of the four supported kebab-case values. A typo used to sneak past Validate
|
||||
// (buildAutomationCreateBody caught it, but only after the cross-family guard
|
||||
// would otherwise fire with a misleading "belongs to type" message).
|
||||
func TestAutomationCreate_UnknownTriggerTypeRejected(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "n", "trigger-type": "bogus"})
|
||||
err := AppsAutomationCreate.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, "--trigger-type")
|
||||
}
|
||||
|
||||
func TestAutomationCreateCron_Sub30MinRejected(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "n", "trigger-type": "cron", "cron": "*/5 * * * *"})
|
||||
err := AppsAutomationCreate.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, "--cron")
|
||||
}
|
||||
|
||||
func TestAutomationCreateRecordChange_MissingEvent(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "n", "trigger-type": "record-change", "table": "tbl"})
|
||||
err := AppsAutomationCreate.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, "--event")
|
||||
}
|
||||
|
||||
func TestAutomationCreateApproval_CodeOptional(t *testing.T) {
|
||||
rctx, _, reg := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "n", "trigger-type": "feishu-approval",
|
||||
"event-type": "approval_instance", "instance-status": "APPROVED"})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/triggers",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"name": "n", "status": "disabled"}},
|
||||
})
|
||||
if err := AppsAutomationCreate.Validate(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("approval without --approval-code must pass validation: %v", err)
|
||||
}
|
||||
if err := AppsAutomationCreate.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationCreateApproval_StatusUppercased asserts that a lowercase status
|
||||
// passed via --instance-status is normalized to the uppercase enum in the body
|
||||
// before it reaches the backend (foundation review: buildApprovalCondition stores
|
||||
// the raw statuses, so create must uppercase them itself).
|
||||
func TestAutomationCreateApproval_StatusUppercased(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "n", "trigger-type": "feishu-approval",
|
||||
"event-type": "approval_instance", "instance-status": "approved"})
|
||||
body, err := buildAutomationCreateBody(rctx)
|
||||
if err != nil {
|
||||
t.Fatalf("buildAutomationCreateBody() = %v", err)
|
||||
}
|
||||
cond, ok := body["feishu_approval_condition"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("feishu_approval_condition missing or wrong type: %+v", body)
|
||||
}
|
||||
statuses, ok := cond["status"].([]string)
|
||||
if !ok {
|
||||
t.Fatalf("status must be []string: %+v", cond)
|
||||
}
|
||||
if len(statuses) != 1 || statuses[0] != "APPROVED" {
|
||||
t.Errorf("lowercase status must be uppercased to APPROVED, got %v", statuses)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationCreate_RedactsWebhookToken covers the bearer-token redaction
|
||||
// reverse invariant on the create path against the real response shape (a
|
||||
// live test-env probe confirmed POST wraps the trigger under a `trigger`
|
||||
// key, same as GET/PUT). The backend create path re-reads the freshly
|
||||
// created trigger and returns it through the same read-path converter used
|
||||
// by get/list — theoretically capable of returning a plaintext bearer
|
||||
// token. Defense-in-depth: CLI create must also redact so every read-shaped
|
||||
// output path is consistently scrubbed.
|
||||
func TestAutomationCreate_RedactsWebhookToken(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "wh1", "trigger-type": "webhook"})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/triggers",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"trigger": map[string]interface{}{
|
||||
"name": "wh1", "trigger_type": "webhook", "status": "disabled",
|
||||
"trigger_condition": map[string]interface{}{
|
||||
"preview_url": "https://p", "runtime_url": "https://r",
|
||||
"token_enabled": true, "token_value": "PLAINTEXT_CREATE_TOKEN",
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err := AppsAutomationCreate.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
out := stdoutBuf.String()
|
||||
if strings.Contains(out, "PLAINTEXT_CREATE_TOKEN") {
|
||||
t.Errorf("create must never surface plaintext token: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationCreate_StatusPassthrough verifies --status is included in the
|
||||
// POST body when set. Backend supports create+enable in one call via the
|
||||
// optional status field; CLI passes it through unchanged.
|
||||
func TestAutomationCreate_StatusPassthrough(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "n", "trigger-type": "cron",
|
||||
"cron": "0 9 * * *", "status": "enabled",
|
||||
})
|
||||
body, err := buildAutomationCreateBody(rctx)
|
||||
if err != nil {
|
||||
t.Fatalf("buildBody: %v", err)
|
||||
}
|
||||
if body["status"] != "enabled" {
|
||||
t.Errorf("status = %v; want enabled", body["status"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationCreate_StatusInvalid: only enabled/disabled accepted.
|
||||
func TestAutomationCreate_StatusInvalid(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "n", "trigger-type": "cron",
|
||||
"cron": "0 9 * * *", "status": "bogus",
|
||||
})
|
||||
_, err := buildAutomationCreateBody(rctx)
|
||||
assertValidationParamError(t, err, "--status")
|
||||
}
|
||||
|
||||
// TestAutomationCreate_StatusOmitted: when --status is not set, body must not
|
||||
// carry a status field — backend applies its default (disabled).
|
||||
func TestAutomationCreate_StatusOmitted(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "n", "trigger-type": "cron",
|
||||
"cron": "0 9 * * *",
|
||||
})
|
||||
body, err := buildAutomationCreateBody(rctx)
|
||||
if err != nil {
|
||||
t.Fatalf("buildBody: %v", err)
|
||||
}
|
||||
if _, present := body["status"]; present {
|
||||
t.Errorf("status must be omitted when --status not set, got %v", body["status"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationCreate_NameTooLong: --name > 100 chars is rejected locally with
|
||||
// a typed --name error, sparing the round trip to the backend.
|
||||
func TestAutomationCreate_NameTooLong(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": strings.Repeat("n", automationNameMaxLen+1),
|
||||
"trigger-type": "cron", "cron": "0 9 * * *",
|
||||
})
|
||||
_, err := buildAutomationCreateBody(rctx)
|
||||
assertValidationParamError(t, err, "--name")
|
||||
}
|
||||
|
||||
// TestAutomationCreate_DescriptionTooLong: --description > 50 chars is rejected
|
||||
// locally with a typed --description error.
|
||||
func TestAutomationCreate_DescriptionTooLong(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "n", "trigger-type": "cron",
|
||||
"cron": "0 9 * * *", "description": strings.Repeat("d", automationDescriptionMaxLen+1),
|
||||
})
|
||||
_, err := buildAutomationCreateBody(rctx)
|
||||
assertValidationParamError(t, err, "--description")
|
||||
}
|
||||
38
shortcuts/apps/apps_automation_disable.go
Normal file
38
shortcuts/apps/apps_automation_disable.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsAutomationDisable disables a trigger. Maps to the shared status endpoint.
|
||||
var AppsAutomationDisable = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+automation-disable",
|
||||
Description: "Disable an automation trigger (stops auto-firing; does not delete)",
|
||||
Risk: "write",
|
||||
Tips: []string{"Example: lark-cli apps +automation-disable --app-id <id> --name <trigger_name>"},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "name", Desc: "trigger name", Required: true},
|
||||
},
|
||||
Validate: automationValidateName,
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
PATCH(automationItemPath(appID, strings.TrimSpace(rctx.Str("name")))).
|
||||
Desc("Disable automation trigger").
|
||||
Body(statusBodyFromAction(false))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
return runAutomationStatus(rctx, false)
|
||||
},
|
||||
}
|
||||
70
shortcuts/apps/apps_automation_enable.go
Normal file
70
shortcuts/apps/apps_automation_enable.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsAutomationEnable enables (activates) a trigger. Maps to the shared status endpoint.
|
||||
var AppsAutomationEnable = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+automation-enable",
|
||||
Description: "Enable (activate) an automation trigger",
|
||||
Risk: "write",
|
||||
Tips: []string{"Example: lark-cli apps +automation-enable --app-id <id> --name <trigger_name>"},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "name", Desc: "trigger name", Required: true},
|
||||
},
|
||||
Validate: automationValidateName,
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
PATCH(automationItemPath(appID, strings.TrimSpace(rctx.Str("name")))).
|
||||
Desc("Enable automation trigger").
|
||||
Body(statusBodyFromAction(true))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
return runAutomationStatus(rctx, true)
|
||||
},
|
||||
}
|
||||
|
||||
// runAutomationStatus is shared by enable/disable: PATCH .../triggers/{name}
|
||||
// with {"status": ...}. The status change happens on the parent resource per
|
||||
// the backend OpenAPI spec (see reference Python samples in the trigger test
|
||||
// fixtures) — there is intentionally no /status sub-path; the sole nested
|
||||
// endpoints under a trigger are the webhook credential lifecycle
|
||||
// (/webhook/token/status, /webhook/token/reset, /webhook/url/reset).
|
||||
//
|
||||
// The status endpoint returns {"success": true} on success. Pretty output is
|
||||
// synthesized from rctx.name and the desired action, since the response
|
||||
// intentionally carries no trigger object to fish name/status from.
|
||||
func runAutomationStatus(rctx *common.RuntimeContext, enable bool) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := strings.TrimSpace(rctx.Str("name"))
|
||||
data, err := rctx.CallAPITyped("PATCH", automationItemPath(appID, name), nil, statusBodyFromAction(enable))
|
||||
if err != nil {
|
||||
return withAppsHint(err, automationNotFoundHint())
|
||||
}
|
||||
desiredStatus := "disabled"
|
||||
if enable {
|
||||
desiredStatus = "enabled"
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "trigger %s status: %s\n", name, desiredStatus)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
73
shortcuts/apps/apps_automation_get.go
Normal file
73
shortcuts/apps/apps_automation_get.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsAutomationGet gets a single trigger's full config (webhook token redacted).
|
||||
var AppsAutomationGet = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+automation-get",
|
||||
Description: "Get an automation trigger's config (webhook Bearer Token redacted)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +automation-get --app-id <app_id> --name <trigger_name>",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "name", Desc: "trigger name", Required: true},
|
||||
},
|
||||
Validate: automationValidateName,
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(automationItemPath(appID, strings.TrimSpace(rctx.Str("name")))).
|
||||
Desc("Get automation trigger")
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := strings.TrimSpace(rctx.Str("name"))
|
||||
data, err := rctx.CallAPITyped("GET", automationItemPath(appID, name), nil, nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, automationNotFoundHint())
|
||||
}
|
||||
redacted := redactWebhookToken(data)
|
||||
trigger, _ := redacted["trigger"].(map[string]interface{})
|
||||
rctx.OutFormat(redacted, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "name: %v\ntype: %v\nstatus: %v\n",
|
||||
trigger["name"], trigger["trigger_type"], trigger["status"])
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// automationValidateName validates --app-id and --name presence. Shared by get/update/enable/disable.
|
||||
func automationValidateName(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("name")) == "" {
|
||||
return appsValidationParamError("--name", "--name is required").
|
||||
WithHint("find trigger names with `lark-cli apps +automation-list --app-id <app_id>`")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// automationNotFoundHint is the shared recovery hint when a trigger name may not exist.
|
||||
func automationNotFoundHint() string {
|
||||
return "verify the trigger name with `lark-cli apps +automation-list --app-id <app_id>`"
|
||||
}
|
||||
117
shortcuts/apps/apps_automation_get_test.go
Normal file
117
shortcuts/apps/apps_automation_get_test.go
Normal file
@@ -0,0 +1,117 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// TestAutomationGetExecute_RedactsWebhookToken pins the redaction invariant
|
||||
// against the actual backend response shape (verified against a live test
|
||||
// environment): GET wraps the trigger under a `trigger` key, so the CLI
|
||||
// must scrub token_value inside data.trigger.trigger_condition. A previous
|
||||
// implementation only scrubbed data.trigger_condition and silently no-op'd
|
||||
// here — this test would fail the moment someone reverts to top-level-only
|
||||
// scrubbing.
|
||||
func TestAutomationGetExecute_RedactsWebhookToken(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t,
|
||||
map[string]string{"app-id": "string", "name": "string"},
|
||||
map[string]string{"app-id": "app_x", "name": "wh1"})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/triggers/wh1",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"trigger": map[string]interface{}{
|
||||
"name": "wh1", "trigger_type": "webhook", "status": "enabled",
|
||||
"trigger_condition": map[string]interface{}{
|
||||
"preview_url": "https://p", "runtime_url": "https://r",
|
||||
"token_enabled": true, "token_value": "PLAINTEXT_SECRET_NESTED",
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err := AppsAutomationGet.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
out := stdoutBuf.String()
|
||||
if strings.Contains(out, "PLAINTEXT_SECRET_NESTED") {
|
||||
t.Errorf("get must never surface plaintext token: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "token_enabled") {
|
||||
t.Errorf("get must expose token_enabled: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationGet_MissingName(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t,
|
||||
map[string]string{"app-id": "string", "name": "string"},
|
||||
map[string]string{"app-id": "app_x"})
|
||||
err := AppsAutomationGet.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, "--name")
|
||||
}
|
||||
|
||||
// TestAutomationGet_MissingAppID covers the sibling branch of Validate:
|
||||
// automationValidateName rejects an empty --app-id before checking --name.
|
||||
func TestAutomationGet_MissingAppID(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t,
|
||||
map[string]string{"app-id": "string", "name": "string"},
|
||||
map[string]string{"name": "t1"})
|
||||
err := AppsAutomationGet.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, "--app-id")
|
||||
}
|
||||
|
||||
// TestAutomationGet_APIErrorAttachesNotFoundHint covers the failure branch of
|
||||
// Execute: a business error on GET must surface typed and carry the
|
||||
// automation-list hint so the caller has a next step.
|
||||
func TestAutomationGet_APIErrorAttachesNotFoundHint(t *testing.T) {
|
||||
rctx, _, reg := newOpenAPIKeyRCtx(t,
|
||||
map[string]string{"app-id": "string", "name": "string"},
|
||||
map[string]string{"app-id": "app_x", "name": "missing"})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/triggers/missing",
|
||||
Body: map[string]interface{}{"code": 400400001, "msg": "trigger not found"},
|
||||
})
|
||||
err := AppsAutomationGet.Execute(context.Background(), rctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected typed api error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI {
|
||||
t.Errorf("category = %q, want %q", p.Category, errs.CategoryAPI)
|
||||
}
|
||||
if p.Subtype == "" {
|
||||
t.Error("subtype must be populated on typed API errors")
|
||||
}
|
||||
if !strings.Contains(p.Hint, "+automation-list") {
|
||||
t.Errorf("hint must point at +automation-list, got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationGet_DryRunPreview exercises the DryRun closure and pins the
|
||||
// GET method + URL pattern that agents inspect before committing.
|
||||
func TestAutomationGet_DryRunPreview(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t,
|
||||
map[string]string{"app-id": "string", "name": "string"},
|
||||
map[string]string{"app-id": "app_x", "name": "t1"})
|
||||
preview := AppsAutomationGet.DryRun(context.Background(), rctx)
|
||||
if preview == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
blob, err := preview.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("marshal preview: %v", err)
|
||||
}
|
||||
got := string(blob)
|
||||
if !strings.Contains(got, `"method":"GET"`) ||
|
||||
!strings.Contains(got, "/apps/app_x/triggers/t1") {
|
||||
t.Errorf("preview missing expected GET/URL fields: %s", got)
|
||||
}
|
||||
}
|
||||
159
shortcuts/apps/apps_automation_list.go
Normal file
159
shortcuts/apps/apps_automation_list.go
Normal file
@@ -0,0 +1,159 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsAutomationList lists an app's automation triggers (all 4 types).
|
||||
var AppsAutomationList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+automation-list",
|
||||
Description: "List a Miaoda app's automation triggers (cron/record-change/webhook/feishu-approval)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +automation-list --app-id <app_id>",
|
||||
"Example: lark-cli apps +automation-list --app-id <app_id> --trigger-type webhook",
|
||||
"Example: lark-cli apps +automation-list --app-id <app_id> --all # aggregate all pages",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "trigger-type", Desc: "filter by type: cron | record-change | webhook | feishu-approval"},
|
||||
{Name: "page-size", Type: "int", Desc: "page size (server default 50, max 100)"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
{Name: "all", Type: "bool", Desc: "auto-aggregate all pages until has_more=false"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if tt := strings.TrimSpace(rctx.Str("trigger-type")); tt != "" {
|
||||
if _, err := mapTriggerType(tt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(automationListPath(appID)).
|
||||
Desc("List automation triggers").
|
||||
Params(buildAutomationListParams(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := automationListPath(appID)
|
||||
params := buildAutomationListParams(rctx)
|
||||
if rctx.Bool("all") {
|
||||
return executeAutomationListAll(rctx, path, params)
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", path, params, nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
return outputAutomationList(rctx, data)
|
||||
},
|
||||
}
|
||||
|
||||
// buildAutomationListParams 组装 list 查询参数。--trigger-type kebab→snake 下推给后端。
|
||||
func buildAutomationListParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{}
|
||||
if tt := strings.TrimSpace(rctx.Str("trigger-type")); tt != "" {
|
||||
if snake, err := mapTriggerType(tt); err == nil {
|
||||
params["trigger_type"] = snake
|
||||
}
|
||||
}
|
||||
if rctx.Changed("page-size") {
|
||||
params["page_size"] = rctx.Int("page-size")
|
||||
}
|
||||
if pt := strings.TrimSpace(rctx.Str("page-token")); pt != "" {
|
||||
params["page_token"] = pt
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// executeAutomationListAll 循环翻页聚合到 has_more=false(禁止静默漏项)。
|
||||
// 用页数上限 + 已见 token 检测防止后端非收敛响应导致无限循环。
|
||||
const automationListAllMaxPages = 100
|
||||
|
||||
func executeAutomationListAll(rctx *common.RuntimeContext, path string, params map[string]interface{}) error {
|
||||
all := make([]interface{}, 0, 16)
|
||||
seen := map[string]struct{}{}
|
||||
token := ""
|
||||
for pages := 0; ; pages++ {
|
||||
if pages >= automationListAllMaxPages {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"pagination did not converge after %d pages", automationListAllMaxPages)
|
||||
}
|
||||
p := make(map[string]interface{}, len(params)+1)
|
||||
for k, v := range params {
|
||||
p[k] = v
|
||||
}
|
||||
if token != "" {
|
||||
p["page_token"] = token
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", path, p, nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
all = append(all, common.GetSlice(data, "items")...)
|
||||
hasMore, next := common.PaginationMeta(data)
|
||||
if !hasMore || next == "" {
|
||||
break
|
||||
}
|
||||
if _, ok := seen[next]; ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"pagination did not converge: page_token %q repeated", next)
|
||||
}
|
||||
seen[next] = struct{}{}
|
||||
token = next
|
||||
}
|
||||
out := map[string]interface{}{"items": all, "has_more": false}
|
||||
return outputAutomationList(rctx, out)
|
||||
}
|
||||
|
||||
// outputAutomationList 输出 items + 分页提示。逐条对 items 套 redactWebhookToken,
|
||||
// 抹掉 trigger_condition.token_value(list/get 恒不返回明文 Bearer Token);
|
||||
// 同时覆盖单页与 --all 聚合路径(executeAutomationListAll 也走这里)。
|
||||
func outputAutomationList(rctx *common.RuntimeContext, data map[string]interface{}) error {
|
||||
items := common.GetSlice(data, "items")
|
||||
redacted := make([]interface{}, 0, len(items))
|
||||
for _, it := range items {
|
||||
if m, ok := it.(map[string]interface{}); ok {
|
||||
redacted = append(redacted, redactWebhookToken(m))
|
||||
} else {
|
||||
redacted = append(redacted, it)
|
||||
}
|
||||
}
|
||||
// 保留分页字段供 PaginationHint/PaginationMeta 读取(读的是同一个 map)。
|
||||
out := map[string]interface{}{
|
||||
"items": redacted,
|
||||
"has_more": data["has_more"],
|
||||
"page_token": data["page_token"],
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%d trigger(s)\n", len(redacted))
|
||||
for _, it := range redacted {
|
||||
if m, ok := it.(map[string]interface{}); ok {
|
||||
fmt.Fprintf(w, "- %v [%v] %v\n", m["name"], m["trigger_type"], m["status"])
|
||||
}
|
||||
}
|
||||
fmt.Fprint(w, common.PaginationHint(out, len(redacted)))
|
||||
})
|
||||
return nil
|
||||
}
|
||||
219
shortcuts/apps/apps_automation_list_test.go
Normal file
219
shortcuts/apps/apps_automation_list_test.go
Normal file
@@ -0,0 +1,219 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func automationListFlagDefs() map[string]string {
|
||||
return map[string]string{
|
||||
"app-id": "string", "trigger-type": "string",
|
||||
"page-size": "int", "page-token": "string", "all": "bool",
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationList_InvalidTriggerTypeFilter covers Validate's mapTriggerType
|
||||
// error branch: an unknown --trigger-type is rejected before any API call, with
|
||||
// a typed error naming the failing flag.
|
||||
func TestAutomationList_InvalidTriggerTypeFilter(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationListFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "trigger-type": "bogus"})
|
||||
err := AppsAutomationList.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, "--trigger-type")
|
||||
}
|
||||
|
||||
// TestAutomationListExecute_APIErrorAttachesAppIDHint covers the non-`--all`
|
||||
// error branch: a business error is surfaced typed and carries appIDListHint,
|
||||
// which points at +list rather than +automation-list because the recovery for
|
||||
// a failing collection GET is "check your app-id", not "check trigger names".
|
||||
func TestAutomationListExecute_APIErrorAttachesAppIDHint(t *testing.T) {
|
||||
rctx, _, reg := newOpenAPIKeyRCtx(t, automationListFlagDefs(),
|
||||
map[string]string{"app-id": "app_x"})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/triggers",
|
||||
Body: map[string]interface{}{"code": 400400002, "msg": "app not accessible"},
|
||||
})
|
||||
err := AppsAutomationList.Execute(context.Background(), rctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected typed api error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI {
|
||||
t.Errorf("category = %q, want %q", p.Category, errs.CategoryAPI)
|
||||
}
|
||||
if p.Subtype == "" {
|
||||
t.Error("subtype must be populated on typed API errors")
|
||||
}
|
||||
if !strings.Contains(p.Hint, "apps +list") {
|
||||
t.Errorf("hint must point at `lark-cli apps +list`, got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationList_DryRunPreview exercises the DryRun closure — pins the GET
|
||||
// method + collection URL + trigger_type param pushdown.
|
||||
func TestAutomationList_DryRunPreview(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationListFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "trigger-type": "webhook"})
|
||||
preview := AppsAutomationList.DryRun(context.Background(), rctx)
|
||||
if preview == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
blob, err := preview.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("marshal preview: %v", err)
|
||||
}
|
||||
got := string(blob)
|
||||
if !strings.Contains(got, `"method":"GET"`) ||
|
||||
!strings.Contains(got, "/apps/app_x/triggers") ||
|
||||
!strings.Contains(got, `"trigger_type":"webhook"`) {
|
||||
t.Errorf("preview missing expected GET/URL/params: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationListMeta(t *testing.T) {
|
||||
if AppsAutomationList.Command != "+automation-list" || AppsAutomationList.Risk != "read" {
|
||||
t.Errorf("meta mismatch: %+v", AppsAutomationList)
|
||||
}
|
||||
if len(AppsAutomationList.Scopes) != 1 || AppsAutomationList.Scopes[0] != "spark:app:read" {
|
||||
t.Errorf("scopes = %v", AppsAutomationList.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationListExecute_SinglePage(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationListFlagDefs(),
|
||||
map[string]string{"app-id": "app_x"})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/triggers",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "", "data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"name": "t_cron", "trigger_type": "cron", "status": "disabled"},
|
||||
map[string]interface{}{"name": "t_wh", "trigger_type": "webhook", "status": "enabled"},
|
||||
},
|
||||
"has_more": false, "page_token": "",
|
||||
}},
|
||||
})
|
||||
if err := AppsAutomationList.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
out := stdoutBuf.String()
|
||||
if !strings.Contains(out, "t_cron") || !strings.Contains(out, "t_wh") {
|
||||
t.Errorf("list must contain both triggers: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// --all aggregates every page until has_more=false. httpmock.Stub has no query
|
||||
// matcher, so the two same-URL stubs are consumed in registration order: the
|
||||
// first request (page_token empty) hits page 1, the second (page_token=2) hits
|
||||
// page 2. See registry.match — a matched non-reusable stub is not reused.
|
||||
func TestAutomationListExecute_AllAggregatesPages(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationListFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "all": "true"})
|
||||
// page 1: has_more=true, page_token="2"
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/triggers",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"name": "p1", "trigger_type": "cron", "status": "disabled"}},
|
||||
"has_more": true, "page_token": "2",
|
||||
}},
|
||||
})
|
||||
// page 2: has_more=false
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/triggers",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"name": "p2", "trigger_type": "webhook", "status": "enabled"}},
|
||||
"has_more": false, "page_token": "",
|
||||
}},
|
||||
})
|
||||
if err := AppsAutomationList.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
out := stdoutBuf.String()
|
||||
if !strings.Contains(out, "p1") || !strings.Contains(out, "p2") {
|
||||
t.Errorf("--all must aggregate both pages: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationListParams_TriggerTypePushdown(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationListFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "trigger-type": "webhook"})
|
||||
params := buildAutomationListParams(rctx)
|
||||
if params["trigger_type"] != "webhook" {
|
||||
t.Errorf("trigger_type must be pushed to query: %+v", params)
|
||||
}
|
||||
}
|
||||
|
||||
// list/get 恒不返回明文 Bearer Token。webhook item 的
|
||||
// trigger_condition.token_value 必须逐条脱敏,token_enabled 保留。
|
||||
func TestAutomationListExecute_RedactsWebhookToken(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationListFlagDefs(),
|
||||
map[string]string{"app-id": "app_x"})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/triggers",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "", "data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "t_wh", "trigger_type": "webhook", "status": "enabled",
|
||||
"trigger_condition": map[string]interface{}{
|
||||
"preview_url": "https://p", "runtime_url": "https://r",
|
||||
"token_enabled": true, "token_value": "PLAINTEXT_LIST_TOKEN",
|
||||
},
|
||||
},
|
||||
},
|
||||
"has_more": false, "page_token": "",
|
||||
}},
|
||||
})
|
||||
if err := AppsAutomationList.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
out := stdoutBuf.String()
|
||||
if strings.Contains(out, "PLAINTEXT_LIST_TOKEN") {
|
||||
t.Errorf("list must never surface plaintext token: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "token_enabled") {
|
||||
t.Errorf("list must expose token_enabled: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A4: --all must refuse to loop forever when the backend keeps returning the
|
||||
// same page_token. A reusable stub that always advertises "has_more=true,
|
||||
// page_token=same" forces the seen-token guard to trip.
|
||||
func TestAutomationListExecute_All_DetectsRepeatedPageToken(t *testing.T) {
|
||||
rctx, _, reg := newOpenAPIKeyRCtx(t, automationListFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "all": "true"})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/triggers",
|
||||
Reusable: true,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"name": "p", "trigger_type": "cron", "status": "disabled"}},
|
||||
"has_more": true, "page_token": "stuck",
|
||||
}},
|
||||
})
|
||||
err := AppsAutomationList.Execute(context.Background(), rctx)
|
||||
// The seen-token detector must raise a typed internal/invalid_response error
|
||||
// long before the caller sees a runaway loop.
|
||||
assertInternalError(t, err, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
|
||||
// A4: --all must also refuse to loop forever when the backend keeps issuing new
|
||||
// distinct page_tokens without ever setting has_more=false. The page-cap kicks
|
||||
// in at automationListAllMaxPages. Simulated by a reusable stub advertising a
|
||||
// fresh non-repeating token via monotonically increasing counter — but since
|
||||
// httpmock has no dynamic bodies, we lean on the fact that the same reusable
|
||||
// body advertises page_token="stuck" (the seen-token guard trips first). This
|
||||
// case is left to the sibling test above; the page-cap constant is asserted
|
||||
// here so a future refactor cannot silently drop the ceiling.
|
||||
func TestAutomationListAll_PageCapConstant(t *testing.T) {
|
||||
if automationListAllMaxPages <= 0 || automationListAllMaxPages > 1000 {
|
||||
t.Errorf("automationListAllMaxPages = %d; must be a small positive ceiling", automationListAllMaxPages)
|
||||
}
|
||||
}
|
||||
23
shortcuts/apps/apps_automation_registration_test.go
Normal file
23
shortcuts/apps/apps_automation_registration_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAutomationCommandsRegistered(t *testing.T) {
|
||||
want := map[string]bool{
|
||||
"+automation-list": false, "+automation-get": false, "+automation-create": false,
|
||||
"+automation-update": false, "+automation-enable": false, "+automation-disable": false,
|
||||
}
|
||||
for _, sc := range Shortcuts() {
|
||||
if _, ok := want[sc.Command]; ok {
|
||||
want[sc.Command] = true
|
||||
}
|
||||
}
|
||||
for cmd, found := range want {
|
||||
if !found {
|
||||
t.Errorf("shortcut %q not registered in Shortcuts()", cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
174
shortcuts/apps/apps_automation_status_test.go
Normal file
174
shortcuts/apps/apps_automation_status_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAutomationEnable_PostsEnabledStatus(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t,
|
||||
map[string]string{"app-id": "string", "name": "string"},
|
||||
map[string]string{"app-id": "app_x", "name": "t1"})
|
||||
rctx.Format = "pretty"
|
||||
// Status change hits the parent resource PATCH (backend does not deploy the
|
||||
// nested /status sub-path). Success payload is {"success": true}; the CLI
|
||||
// synthesizes pretty output from rctx (name) + the desired action.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH", URL: "/open-apis/spark/v1/apps/app_x/triggers/t1",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"success": true}},
|
||||
})
|
||||
if err := AppsAutomationEnable.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdoutBuf.String(), "trigger t1 status: enabled") {
|
||||
t.Errorf("enable output = %q", stdoutBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationDisable_PostsDisabledStatus(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t,
|
||||
map[string]string{"app-id": "string", "name": "string"},
|
||||
map[string]string{"app-id": "app_x", "name": "t1"})
|
||||
rctx.Format = "pretty"
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH", URL: "/open-apis/spark/v1/apps/app_x/triggers/t1",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"success": true}},
|
||||
})
|
||||
if err := AppsAutomationDisable.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdoutBuf.String(), "trigger t1 status: disabled") {
|
||||
t.Errorf("disable output = %q", stdoutBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationEnableDisableMeta(t *testing.T) {
|
||||
if AppsAutomationEnable.Risk != "write" || AppsAutomationDisable.Risk != "write" {
|
||||
t.Error("enable/disable must be Risk=write")
|
||||
}
|
||||
if AppsAutomationEnable.Command != "+automation-enable" || AppsAutomationDisable.Command != "+automation-disable" {
|
||||
t.Error("command names mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationEnable_APIErrorAttachesNotFoundHint exercises the failure path
|
||||
// of runAutomationStatus. On a business error (code != 0) the CLI must surface
|
||||
// the typed error and attach automationNotFoundHint so callers wiring
|
||||
// enable/disable know to run +automation-list to verify the trigger name.
|
||||
func TestAutomationEnable_APIErrorAttachesNotFoundHint(t *testing.T) {
|
||||
rctx, _, reg := newOpenAPIKeyRCtx(t,
|
||||
map[string]string{"app-id": "string", "name": "string"},
|
||||
map[string]string{"app-id": "app_x", "name": "missing"})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH", URL: "/open-apis/spark/v1/apps/app_x/triggers/missing",
|
||||
Body: map[string]interface{}{"code": 400400001, "msg": "trigger not found"},
|
||||
})
|
||||
err := AppsAutomationEnable.Execute(context.Background(), rctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected typed api error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
// Per AGENTS.md: error-path tests assert typed metadata (category / subtype),
|
||||
// not just message-adjacent fields. Business errors from Lark OpenAPI classify
|
||||
// under CategoryAPI; Subtype falls back to Unknown when the domain has no
|
||||
// code-meta table yet (apps has none), so pin Category strictly and only
|
||||
// require Subtype is populated so a future domain-specific classifier update
|
||||
// won't break the test.
|
||||
if p.Category != errs.CategoryAPI {
|
||||
t.Errorf("category = %q, want %q", p.Category, errs.CategoryAPI)
|
||||
}
|
||||
if p.Subtype == "" {
|
||||
t.Error("subtype must be populated on typed API errors")
|
||||
}
|
||||
if p.Code != 400400001 {
|
||||
t.Errorf("code = %d, want 400400001", p.Code)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "+automation-list") {
|
||||
t.Errorf("hint must point at +automation-list, got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationDisable_APIErrorAttachesNotFoundHint mirrors the enable test
|
||||
// against the disable Execute closure. Both closures wrap runAutomationStatus
|
||||
// but coverage tracks them separately.
|
||||
func TestAutomationDisable_APIErrorAttachesNotFoundHint(t *testing.T) {
|
||||
rctx, _, reg := newOpenAPIKeyRCtx(t,
|
||||
map[string]string{"app-id": "string", "name": "string"},
|
||||
map[string]string{"app-id": "app_x", "name": "missing"})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH", URL: "/open-apis/spark/v1/apps/app_x/triggers/missing",
|
||||
Body: map[string]interface{}{"code": 400400001, "msg": "trigger not found"},
|
||||
})
|
||||
err := AppsAutomationDisable.Execute(context.Background(), rctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected typed api error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI {
|
||||
t.Errorf("category = %q, want %q", p.Category, errs.CategoryAPI)
|
||||
}
|
||||
if p.Subtype == "" {
|
||||
t.Error("subtype must be populated on typed API errors")
|
||||
}
|
||||
if p.Code != 400400001 {
|
||||
t.Errorf("code = %d, want 400400001", p.Code)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "+automation-list") {
|
||||
t.Errorf("hint must point at +automation-list, got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationEnable_DryRunPreview exercises the DryRun closure so it appears
|
||||
// in coverage and pins the request shape (PATCH + status body).
|
||||
func TestAutomationEnable_DryRunPreview(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t,
|
||||
map[string]string{"app-id": "string", "name": "string"},
|
||||
map[string]string{"app-id": "app_x", "name": "t1"})
|
||||
preview := AppsAutomationEnable.DryRun(context.Background(), rctx)
|
||||
if preview == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
blob, err := preview.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("marshal preview: %v", err)
|
||||
}
|
||||
got := string(blob)
|
||||
if !strings.Contains(got, `"method":"PATCH"`) ||
|
||||
!strings.Contains(got, "/apps/app_x/triggers/t1") ||
|
||||
!strings.Contains(got, `"status":"enabled"`) {
|
||||
t.Errorf("preview missing expected PATCH/URL/body fields: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationDisable_DryRunPreview(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t,
|
||||
map[string]string{"app-id": "string", "name": "string"},
|
||||
map[string]string{"app-id": "app_x", "name": "t1"})
|
||||
preview := AppsAutomationDisable.DryRun(context.Background(), rctx)
|
||||
if preview == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
blob, err := preview.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("marshal preview: %v", err)
|
||||
}
|
||||
got := string(blob)
|
||||
if !strings.Contains(got, `"method":"PATCH"`) ||
|
||||
!strings.Contains(got, "/apps/app_x/triggers/t1") ||
|
||||
!strings.Contains(got, `"status":"disabled"`) {
|
||||
t.Errorf("preview missing expected PATCH/URL/body fields: %s", got)
|
||||
}
|
||||
}
|
||||
385
shortcuts/apps/apps_automation_update.go
Normal file
385
shortcuts/apps/apps_automation_update.go
Normal file
@@ -0,0 +1,385 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsAutomationUpdate is the unified trigger-modify entry. Webhook URL/Token
|
||||
// actions dispatch to apps_automation_webhook.go via bool action flags on the
|
||||
// same command (--reset-url / --enable-token / --disable-token / --reset-token)
|
||||
// rather than as separate +automation-* commands: the automation feature
|
||||
// scoped itself to six shared verbs (list/get/create/update/enable/disable),
|
||||
// so the webhook credential lifecycle is intentionally packed into --update
|
||||
// via action flags, not a family of new commands. Otherwise Execute sends a
|
||||
// PUT to update the trigger condition.
|
||||
var AppsAutomationUpdate = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+automation-update",
|
||||
Description: "Update a trigger's condition/description, or manage webhook URL/Token via dedicated flags",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +automation-update --app-id <id> --name t1 --trigger-type cron --cron '0 10 * * *' --yes",
|
||||
"Example: lark-cli apps +automation-update --app-id <id> --name rc1 --trigger-type record-change --table <tbl> --event UPDATE --fields '[\"fld1\"]' --yes",
|
||||
"Example: lark-cli apps +automation-update --app-id <id> --name apv --trigger-type feishu-approval --event-type approval_instance --instance-status APPROVED --yes",
|
||||
"Example: lark-cli apps +automation-update --app-id <id> --name wh1 --reset-url --app-env preview --yes",
|
||||
"Example: lark-cli apps +automation-update --app-id <id> --name wh1 --enable-token --yes",
|
||||
"Example: lark-cli apps +automation-update --app-id <id> --name wh1 --white-ip-list '[\"1.1.1.1\"]' --yes",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "name", Desc: "trigger name", Required: true},
|
||||
{Name: "trigger-type", Desc: "type of the trigger being updated (for condition PATCH)"},
|
||||
{Name: "description", Desc: "new description"},
|
||||
{Name: "cron", Desc: "[cron] new 5-field cron expression"},
|
||||
{Name: "timezone", Desc: "[cron] new timezone"},
|
||||
{Name: "table", Desc: "[record-change] table name (from `+db-table-list`); dataloom tables key by name, not id"},
|
||||
{Name: "event", Desc: "[record-change] INSERT | UPDATE | UPSERT | DELETE"},
|
||||
{Name: "fields", Desc: "[record-change] JSON array of field ids for UPDATE/UPSERT, [\"*\"] = all"},
|
||||
{Name: "approval-code", Desc: "[feishu-approval] approval definition code; omit to match all approval definitions"},
|
||||
{Name: "event-type", Desc: "[feishu-approval] approval_instance | approval_task"},
|
||||
{Name: "instance-status", Type: "string_array", Desc: "[feishu-approval] statuses for approval_instance"},
|
||||
{Name: "task-status", Type: "string_array", Desc: "[feishu-approval] statuses for approval_task"},
|
||||
{Name: "white-ip-list", Desc: "[webhook] full replacement JSON array of allowed IPs"},
|
||||
{Name: "reset-url", Type: "bool", Desc: "[webhook] rotate callback URL for --app-env (old URL invalidated)"},
|
||||
{Name: "app-env", Desc: "[webhook] preview | runtime (required with --reset-url)"},
|
||||
{Name: "enable-token", Type: "bool", Desc: "[webhook] enable bearer token (shown once)"},
|
||||
{Name: "disable-token", Type: "bool", Desc: "[webhook] disable bearer token; re-enable generates a new token"},
|
||||
{Name: "reset-token", Type: "bool", Desc: "[webhook] rotate bearer token (old token invalidated, shown once)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if err := automationValidateName(ctx, rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
// --app-env is only consumed by --reset-url; on any other update path
|
||||
// (other webhook action, condition update) it was silently dropped and
|
||||
// dry-run happily previewed the request that DID reach the backend,
|
||||
// misleading callers who inspected --dry-run before committing. Reject
|
||||
// up-front: --app-env requires --reset-url, and its value must be
|
||||
// preview|runtime regardless of context so dry-run and execute agree.
|
||||
if appEnv := strings.TrimSpace(rctx.Str("app-env")); appEnv != "" {
|
||||
if !rctx.Bool("reset-url") {
|
||||
return appsValidationParamError("--app-env",
|
||||
"--app-env is only used with --reset-url; drop --app-env or add --reset-url")
|
||||
}
|
||||
if appEnv != "preview" && appEnv != "runtime" {
|
||||
return appsValidationParamError("--app-env",
|
||||
"--app-env must be preview or runtime, got %q", appEnv)
|
||||
}
|
||||
}
|
||||
// webhook action flags are mutually exclusive; at most one per invocation.
|
||||
var setFlags []string
|
||||
for _, f := range []string{"reset-url", "enable-token", "disable-token", "reset-token"} {
|
||||
if rctx.Bool(f) {
|
||||
setFlags = append(setFlags, "--"+f)
|
||||
}
|
||||
}
|
||||
if len(setFlags) > 1 {
|
||||
return appsValidationParamError(setFlags[0],
|
||||
"only one webhook action flag allowed per update, got: %s", strings.Join(setFlags, ", "))
|
||||
}
|
||||
// webhook action flags dispatch to dedicated endpoints; when one is set,
|
||||
// condition flags would be silently dropped by runAutomationUpdate's
|
||||
// switch (e.g. `--reset-token --cron '0 9 * * *'` used to only reset the
|
||||
// token). Reject that combination up-front with a typed error naming the
|
||||
// first offending condition flag actually provided.
|
||||
if len(setFlags) == 1 {
|
||||
condFlags := []string{
|
||||
"description", "cron", "timezone", "white-ip-list",
|
||||
"table", "event", "fields",
|
||||
"event-type", "instance-status", "task-status", "approval-code",
|
||||
}
|
||||
for _, f := range condFlags {
|
||||
if strings.TrimSpace(rctx.Str(f)) != "" || len(rctx.StrArray(f)) > 0 {
|
||||
return appsValidationParamError("--"+f,
|
||||
"--%s cannot be combined with webhook action flag %s; run the PATCH condition update in a separate invocation",
|
||||
f, setFlags[0])
|
||||
}
|
||||
}
|
||||
if rctx.Bool("reset-url") && strings.TrimSpace(rctx.Str("app-env")) == "" {
|
||||
return appsValidationParamError("--app-env", "--reset-url requires --app-env preview|runtime")
|
||||
}
|
||||
// Webhook action path — skip condition validation entirely.
|
||||
return nil
|
||||
}
|
||||
|
||||
// Condition path. Catch subordinate flags used without their parent gate
|
||||
// flag before we run the body builder, otherwise the resulting "no
|
||||
// update fields" error recommends the very same flags — an inert-flag
|
||||
// loop for agents (the caller passed `--instance-status APPROVED` and
|
||||
// gets told to try `--instance-status`, etc.). Point at the missing
|
||||
// parent instead.
|
||||
if err := checkUpdateSubordinateFlags(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// --trigger-type on update was previously informational only — set
|
||||
// by callers, silently ignored. Two hazards followed:
|
||||
// 1. --trigger-type bogus passed local validation
|
||||
// 2. --cron '0 9 * * *' --white-ip-list '["1.1.1.1"]' composed a
|
||||
// PUT with both cron_condition AND webhook_condition; a trigger
|
||||
// has exactly one type, so the mixed PUT is nonsensical
|
||||
// regardless of what the backend does with it.
|
||||
// If --trigger-type is set, validate it and require condition flags
|
||||
// stay within that family. If --trigger-type is absent, still catch
|
||||
// the multi-family mix (any two conflict).
|
||||
families := familiesInUse(rctx)
|
||||
if cliType := strings.TrimSpace(rctx.Str("trigger-type")); cliType != "" {
|
||||
if _, err := mapTriggerType(cliType); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectCrossFamilyCondFlags(rctx, cliType); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if len(families) > 1 {
|
||||
// Deterministic ordering: pick the first flag from the family
|
||||
// that would end up mixed with another, matching the create
|
||||
// path's error surface.
|
||||
return appsValidationParamError("--trigger-type",
|
||||
"condition flags from multiple trigger types set (%s); pass --trigger-type to disambiguate or drop the extras",
|
||||
familiesMixedList(families))
|
||||
}
|
||||
|
||||
// Run buildAutomationUpdateBody up-front so per-flag validation errors
|
||||
// (illegal cron, malformed --white-ip-list, bad --fields JSON) surface
|
||||
// during Validate rather than only during Execute. Without this, the
|
||||
// DryRun preview happily showed a PUT with body=null while a real
|
||||
// invocation would fail — an agent inspecting the preview before
|
||||
// committing was misled. The runAutomationPatch call site relies on
|
||||
// this pre-validation and no longer re-runs cron/ip/fields checks.
|
||||
body, err := buildAutomationUpdateBody(rctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return noUpdateFieldsError()
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
name := strings.TrimSpace(rctx.Str("name"))
|
||||
switch {
|
||||
case rctx.Bool("reset-url"):
|
||||
return common.NewDryRunAPI().
|
||||
POST(automationWebhookURLResetPath(appID, name)).
|
||||
Desc("Reset webhook URL").
|
||||
Body(webhookURLResetBody(rctx.Str("app-env")))
|
||||
case rctx.Bool("enable-token"):
|
||||
return common.NewDryRunAPI().
|
||||
PATCH(automationWebhookTokenStatusPath(appID, name)).
|
||||
Desc("Set webhook token status").
|
||||
Body(webhookTokenStatusBody(true))
|
||||
case rctx.Bool("disable-token"):
|
||||
return common.NewDryRunAPI().
|
||||
PATCH(automationWebhookTokenStatusPath(appID, name)).
|
||||
Desc("Set webhook token status").
|
||||
Body(webhookTokenStatusBody(false))
|
||||
case rctx.Bool("reset-token"):
|
||||
return common.NewDryRunAPI().
|
||||
POST(automationWebhookTokenResetPath(appID, name)).
|
||||
Desc("Reset webhook token").
|
||||
Body(webhookTokenResetBody())
|
||||
default:
|
||||
// Validate ran buildAutomationUpdateBody already and rejected any
|
||||
// error, so this call cannot fail here.
|
||||
body, _ := buildAutomationUpdateBody(rctx)
|
||||
return common.NewDryRunAPI().PUT(automationItemPath(appID, name)).Desc("Update trigger condition").Body(body)
|
||||
}
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
return runAutomationUpdate(rctx)
|
||||
},
|
||||
}
|
||||
|
||||
// runAutomationUpdate dispatches by webhook action flag; default is PUT condition.
|
||||
func runAutomationUpdate(rctx *common.RuntimeContext) error {
|
||||
switch {
|
||||
case rctx.Bool("reset-url"):
|
||||
return runWebhookURLReset(rctx)
|
||||
case rctx.Bool("enable-token"):
|
||||
return runWebhookTokenStatus(rctx, true)
|
||||
case rctx.Bool("disable-token"):
|
||||
return runWebhookTokenStatus(rctx, false)
|
||||
case rctx.Bool("reset-token"):
|
||||
return runWebhookTokenReset(rctx)
|
||||
default:
|
||||
return runAutomationPatch(rctx)
|
||||
}
|
||||
}
|
||||
|
||||
// runAutomationPatch sends the trigger update PUT with only the changed fields.
|
||||
// Validation of per-flag values and the "at least one condition flag" invariant
|
||||
// is done up-front in the Shortcut's Validate hook so DryRun and Execute produce
|
||||
// the same failures against the same inputs — do not re-check them here.
|
||||
func runAutomationPatch(rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := strings.TrimSpace(rctx.Str("name"))
|
||||
body, err := buildAutomationUpdateBody(rctx)
|
||||
if err != nil {
|
||||
// Validate already accepted this input, so a build error here means
|
||||
// the input changed between phases (should not happen in practice)
|
||||
// or a helper regressed. Surface it verbatim rather than swallowing.
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("PUT", automationItemPath(appID, name), nil, body)
|
||||
if err != nil {
|
||||
return withAppsHint(err, automationNotFoundHint())
|
||||
}
|
||||
// Bearer-token redaction reverse invariant: the plaintext webhook bearer
|
||||
// token is only ever surfaced by the dedicated one-shot flags
|
||||
// --enable-token / --reset-token. Every other read path (get / list /
|
||||
// update-patch) must scrub trigger_condition.token_value. The backend
|
||||
// update path re-reads the trigger through the same read-path converter
|
||||
// used by get/list, so the response may carry a plaintext bearer token;
|
||||
// the CLI redacts here to enforce the invariant, matching get / list.
|
||||
redacted := redactWebhookToken(data)
|
||||
trigger, _ := redacted["trigger"].(map[string]interface{})
|
||||
rctx.OutFormat(redacted, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "updated trigger: %v\n", trigger["name"])
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkUpdateSubordinateFlags surfaces "requires --parent" errors for flags
|
||||
// that only make sense in combination with a parent condition-gate flag.
|
||||
// Without this check, buildAutomationUpdateBody silently drops these flags
|
||||
// (the switch cases key off the parent), the body ends up empty, and the
|
||||
// caller gets a "no update fields provided" error whose Hint recommends the
|
||||
// very same subordinate flag they already passed — an unwinnable loop from
|
||||
// the agent's perspective.
|
||||
func checkUpdateSubordinateFlags(rctx *common.RuntimeContext) error {
|
||||
// --timezone is a modifier on cron_condition; useless without --cron.
|
||||
if strings.TrimSpace(rctx.Str("timezone")) != "" && strings.TrimSpace(rctx.Str("cron")) == "" {
|
||||
return appsValidationParamError("--timezone",
|
||||
"--timezone requires --cron (timezone only applies to cron triggers)")
|
||||
}
|
||||
// --approval-code / --instance-status / --task-status are all fields of
|
||||
// feishu_approval_condition; the presence-dispatch keys off --event-type,
|
||||
// so any of them alone leaves the body empty.
|
||||
eventType := strings.TrimSpace(rctx.Str("event-type"))
|
||||
if eventType == "" {
|
||||
if strings.TrimSpace(rctx.Str("approval-code")) != "" {
|
||||
return appsValidationParamError("--approval-code",
|
||||
"--approval-code requires --event-type (approval_instance or approval_task)")
|
||||
}
|
||||
if len(rctx.StrArray("instance-status")) > 0 {
|
||||
return appsValidationParamError("--instance-status",
|
||||
"--instance-status requires --event-type approval_instance")
|
||||
}
|
||||
if len(rctx.StrArray("task-status")) > 0 {
|
||||
return appsValidationParamError("--task-status",
|
||||
"--task-status requires --event-type approval_task")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Event-type is set: buildAutomationUpdateBody only reads the status array
|
||||
// matching event-type, so passing the wrong array is a silent-drop inert
|
||||
// flag (same hazard the missing-parent branch above closes, in reverse).
|
||||
// Reject up-front and name the mismatched flag as the failing Param.
|
||||
if eventType == "approval_instance" && len(rctx.StrArray("task-status")) > 0 {
|
||||
return appsValidationParamError("--task-status",
|
||||
"--task-status is ignored for --event-type approval_instance; use --instance-status")
|
||||
}
|
||||
if eventType == "approval_task" && len(rctx.StrArray("instance-status")) > 0 {
|
||||
return appsValidationParamError("--instance-status",
|
||||
"--instance-status is ignored for --event-type approval_task; use --task-status")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// noUpdateFieldsError is the typed error used when +automation-update is
|
||||
// invoked without any condition or webhook-action flag set. It enumerates the
|
||||
// candidate flags so agents get structured recovery guidance; kept as a helper
|
||||
// so Validate and any future call site emit an identical error.
|
||||
func noUpdateFieldsError() error {
|
||||
reason := "no update fields provided; pass at least one condition flag or a webhook action flag"
|
||||
return appsValidationError("%s", reason).
|
||||
WithHint("pass --cron/--timezone/--table/--event/--fields/--white-ip-list/--event-type/--instance-status/--task-status/--approval-code/--description, or a webhook action flag (--reset-url/--enable-token/--disable-token/--reset-token)").
|
||||
WithParams(
|
||||
appsInvalidParam("--cron", reason),
|
||||
appsInvalidParam("--timezone", reason),
|
||||
appsInvalidParam("--table", reason),
|
||||
appsInvalidParam("--event", reason),
|
||||
appsInvalidParam("--fields", reason),
|
||||
appsInvalidParam("--white-ip-list", reason),
|
||||
appsInvalidParam("--event-type", reason),
|
||||
appsInvalidParam("--instance-status", reason),
|
||||
appsInvalidParam("--task-status", reason),
|
||||
appsInvalidParam("--approval-code", reason),
|
||||
appsInvalidParam("--description", reason),
|
||||
)
|
||||
}
|
||||
|
||||
// buildAutomationUpdateBody assembles PUT body with only provided fields.
|
||||
// Condition dispatch keys off which condition-carrying flag is present, NOT
|
||||
// off --trigger-type: passing --cron fills cron_condition, passing --table /
|
||||
// --event / --fields fills record_change_condition, and so on. --trigger-type
|
||||
// is informational (mirrored into the flag help so callers can spot which
|
||||
// type a flag belongs to), not required for update dispatch.
|
||||
func buildAutomationUpdateBody(rctx *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
body := map[string]interface{}{}
|
||||
if d := strings.TrimSpace(rctx.Str("description")); d != "" {
|
||||
if err := validateAutomationDescriptionLen(d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body["description"] = d
|
||||
}
|
||||
if c := strings.TrimSpace(rctx.Str("cron")); c != "" {
|
||||
cond, err := buildCronCondition(c, rctx.Str("timezone"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body["cron_condition"] = cond
|
||||
}
|
||||
if raw := strings.TrimSpace(rctx.Str("white-ip-list")); raw != "" {
|
||||
ipList, err := parseIPListFlag(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body["webhook_condition"] = buildWebhookCondition(ipList)
|
||||
}
|
||||
// record-change dispatch: any of --table/--event/--fields triggers a rebuild.
|
||||
// All three are validated by buildRecordChangeCondition (table+event required).
|
||||
if strings.TrimSpace(rctx.Str("table")) != "" ||
|
||||
strings.TrimSpace(rctx.Str("event")) != "" ||
|
||||
strings.TrimSpace(rctx.Str("fields")) != "" {
|
||||
fields, err := parseFieldsFlag(rctx.Str("fields"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cond, err := buildRecordChangeCondition(rctx.Str("table"), rctx.Str("event"), fields)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body["record_change_condition"] = cond
|
||||
}
|
||||
// feishu-approval dispatch: --event-type is the gate flag. Statuses are picked
|
||||
// from --instance-status or --task-status per event-type.
|
||||
if eventType := strings.TrimSpace(rctx.Str("event-type")); eventType != "" {
|
||||
raw := rctx.StrArray("instance-status")
|
||||
if eventType == "approval_task" {
|
||||
raw = rctx.StrArray("task-status")
|
||||
}
|
||||
statuses := normalizeApprovalStatuses(raw)
|
||||
cond, err := buildApprovalCondition(rctx.Str("approval-code"), eventType, statuses)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body["feishu_approval_condition"] = cond
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
444
shortcuts/apps/apps_automation_update_test.go
Normal file
444
shortcuts/apps/apps_automation_update_test.go
Normal file
@@ -0,0 +1,444 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAutomationUpdate_PatchCronOnly(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "t1", "trigger-type": "cron", "cron": "0 10 * * *"})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT", URL: "/open-apis/spark/v1/apps/app_x/triggers/t1",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"name": "t1", "trigger_type": "cron"}},
|
||||
})
|
||||
if err := runAutomationUpdate(rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdoutBuf.String(), "t1") {
|
||||
t.Errorf("update output = %s", stdoutBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_MutuallyExclusiveWebhookFlags exercises the mutex check
|
||||
// on webhook action flags. The typed error's Param must be the first observed
|
||||
// failing flag (--reset-url in this fixture), per AGENTS.md: Param names only
|
||||
// actual failed user input.
|
||||
func TestAutomationUpdate_MutuallyExclusiveWebhookFlags(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "t1", "reset-url": "true", "reset-token": "true"})
|
||||
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, "--reset-url")
|
||||
}
|
||||
|
||||
func TestAutomationUpdate_WhiteIPListPatch(t *testing.T) {
|
||||
rctx, _, reg := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "wh1", "trigger-type": "webhook", "white-ip-list": `["1.1.1.1"]`})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT", URL: "/open-apis/spark/v1/apps/app_x/triggers/wh1",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"name": "wh1"}},
|
||||
})
|
||||
if err := runAutomationUpdate(rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationUpdate_InvalidCronRejected(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "t1", "trigger-type": "cron", "cron": "*/5 * * * *"})
|
||||
err := runAutomationUpdate(rctx)
|
||||
assertValidationParamError(t, err, "--cron")
|
||||
}
|
||||
|
||||
func TestAutomationUpdate_InvalidWhiteIPListRejected(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "wh1", "trigger-type": "webhook", "white-ip-list": "{bad json"})
|
||||
err := runAutomationUpdate(rctx)
|
||||
assertValidationParamError(t, err, "--white-ip-list")
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_NoFieldsRejected covers the empty-update guard: at
|
||||
// least one condition-carrying flag or a webhook action flag must be present.
|
||||
// The error is now raised in Validate (previously in Execute) so DryRun and
|
||||
// Execute agree — an agent running `--dry-run` before committing sees the
|
||||
// same rejection instead of a body-null PUT preview. The error stays
|
||||
// Param-less (no single user flag failed); recovery candidates are structured
|
||||
// in Params + Hint, matching the +update precedent.
|
||||
func TestAutomationUpdate_NoFieldsRejected(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "t1"})
|
||||
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
|
||||
if err == nil {
|
||||
t.Fatal("empty update must be rejected")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Category != errs.CategoryValidation {
|
||||
t.Errorf("category = %s, want %s", ve.Category, errs.CategoryValidation)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if ve.Param != "" {
|
||||
t.Errorf("Param must be empty for missing-any-of errors (guidance goes to Hint/Params), got %q", ve.Param)
|
||||
}
|
||||
if ve.Hint == "" {
|
||||
t.Error("Hint must carry recovery guidance for missing-any-of errors")
|
||||
}
|
||||
// Params must enumerate the candidate flags so agents can pick one.
|
||||
if len(ve.Params) < 5 {
|
||||
t.Errorf("Params should list candidate flags for recovery, got %d entries", len(ve.Params))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_ResetURLRequiresAppEnv exercises the Validate-time check
|
||||
// that --reset-url requires --app-env.
|
||||
func TestAutomationUpdate_ResetURLRequiresAppEnv(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "wh1", "reset-url": "true"})
|
||||
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, "--app-env")
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_AppEnvRequiresResetURL: --app-env is only consumed by
|
||||
// --reset-url. Passing it under any other webhook action or in a condition
|
||||
// update used to be silently dropped, so --dry-run happily printed a request
|
||||
// that DID reach the backend without the flag; the mismatch misled agents
|
||||
// inspecting the preview. Validate now rejects up-front.
|
||||
func TestAutomationUpdate_AppEnvRequiresResetURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
}{
|
||||
{"with_enable_token",
|
||||
map[string]string{"app-id": "app_x", "name": "wh1", "enable-token": "true", "app-env": "preview"}},
|
||||
{"with_disable_token",
|
||||
map[string]string{"app-id": "app_x", "name": "wh1", "disable-token": "true", "app-env": "preview"}},
|
||||
{"with_reset_token",
|
||||
map[string]string{"app-id": "app_x", "name": "wh1", "reset-token": "true", "app-env": "preview"}},
|
||||
{"with_cron_condition",
|
||||
map[string]string{"app-id": "app_x", "name": "wh1", "cron": "0 9 * * *", "app-env": "preview"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(), tc.flags)
|
||||
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, "--app-env")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_AppEnvInvalidValueRejected: --app-env must be
|
||||
// preview|runtime. Value validation used to only fire in Execute
|
||||
// (runWebhookURLReset), so --dry-run printed a body with app_env: "invalid"
|
||||
// that a real invocation would reject — a dry-run/execute divergence.
|
||||
// Validate now catches invalid values so dry-run and execute agree.
|
||||
func TestAutomationUpdate_AppEnvInvalidValueRejected(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "wh1", "reset-url": "true", "app-env": "invalid"})
|
||||
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, "--app-env")
|
||||
if !strings.Contains(err.Error(), "preview or runtime") {
|
||||
t.Errorf("expected preview/runtime guidance, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_PatchRecordChange covers A5: --trigger-type record-change
|
||||
// with --table/--event dispatches to record_change_condition rebuild.
|
||||
func TestAutomationUpdate_PatchRecordChange(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "rc1", "trigger-type": "record-change",
|
||||
"table": "tbl_1", "event": "UPDATE", "fields": `["fld1"]`,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT", URL: "/open-apis/spark/v1/apps/app_x/triggers/rc1",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"name": "rc1", "trigger_type": "record_change"}},
|
||||
})
|
||||
if err := runAutomationUpdate(rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdoutBuf.String(), "rc1") {
|
||||
t.Errorf("update output = %s", stdoutBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_PatchRecordChange_MissingEvent covers A5 error path:
|
||||
// --table without --event surfaces a typed error keyed on --event.
|
||||
func TestAutomationUpdate_PatchRecordChange_MissingEvent(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "rc1", "trigger-type": "record-change",
|
||||
"table": "tbl_1",
|
||||
})
|
||||
err := runAutomationUpdate(rctx)
|
||||
assertValidationParamError(t, err, "--event")
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_PatchRecordChange_InvalidFieldsJSON covers A5: bad JSON
|
||||
// in --fields is rejected up-front by parseFieldsFlag with Param=--fields.
|
||||
func TestAutomationUpdate_PatchRecordChange_InvalidFieldsJSON(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "rc1", "trigger-type": "record-change",
|
||||
"table": "tbl_1", "event": "UPDATE", "fields": "{bad json",
|
||||
})
|
||||
err := runAutomationUpdate(rctx)
|
||||
assertValidationParamError(t, err, "--fields")
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_PatchApproval covers A5: feishu-approval dispatch.
|
||||
func TestAutomationUpdate_PatchApproval(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "apv", "trigger-type": "feishu-approval",
|
||||
"event-type": "approval_instance", "instance-status": "approved",
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT", URL: "/open-apis/spark/v1/apps/app_x/triggers/apv",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"name": "apv", "trigger_type": "feishu_approval"}},
|
||||
})
|
||||
if err := runAutomationUpdate(rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdoutBuf.String(), "apv") {
|
||||
t.Errorf("update output = %s", stdoutBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_PatchApproval_TaskEventStatuses verifies that
|
||||
// approval_task pulls its statuses from --task-status (not --instance-status).
|
||||
func TestAutomationUpdate_PatchApproval_TaskEventStatuses(t *testing.T) {
|
||||
rctx, _, reg := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "apv", "trigger-type": "feishu-approval",
|
||||
"event-type": "approval_task", "task-status": "DONE",
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT", URL: "/open-apis/spark/v1/apps/app_x/triggers/apv",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"name": "apv"}},
|
||||
})
|
||||
if err := runAutomationUpdate(rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_PatchApproval_MissingStatuses: --event-type without
|
||||
// --instance-status / --task-status surfaces a typed error keyed on the status
|
||||
// flag matching the event-type.
|
||||
func TestAutomationUpdate_PatchApproval_MissingStatuses(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "apv", "trigger-type": "feishu-approval",
|
||||
"event-type": "approval_instance",
|
||||
})
|
||||
err := runAutomationUpdate(rctx)
|
||||
assertValidationParamError(t, err, "--instance-status")
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_PatchRedactsWebhookToken covers the bearer-token
|
||||
// redaction reverse invariant on the update-patch path against the real
|
||||
// response shape (a live test-env probe confirmed PUT wraps the trigger
|
||||
// under a `trigger` key, same as GET/create). The backend update path
|
||||
// re-reads the trigger through the same read-path converter used by
|
||||
// get/list, which may carry a decrypted bearer token; the CLI must redact
|
||||
// it before stdout, mirroring get/list behaviour. Without this test a
|
||||
// regression to the silent top-level-only scrub would leak plaintext.
|
||||
func TestAutomationUpdate_PatchRedactsWebhookToken(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "wh1", "trigger-type": "webhook",
|
||||
"white-ip-list": `["1.1.1.1"]`,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT", URL: "/open-apis/spark/v1/apps/app_x/triggers/wh1",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"trigger": map[string]interface{}{
|
||||
"name": "wh1", "trigger_type": "webhook", "status": "enabled",
|
||||
"trigger_condition": map[string]interface{}{
|
||||
"preview_url": "https://p", "runtime_url": "https://r",
|
||||
"token_enabled": true, "token_value": "PLAINTEXT_PATCH_TOKEN",
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err := runAutomationUpdate(rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
out := stdoutBuf.String()
|
||||
if strings.Contains(out, "PLAINTEXT_PATCH_TOKEN") {
|
||||
t.Errorf("update PATCH must never surface plaintext token: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "token_enabled") {
|
||||
t.Errorf("update PATCH must still expose token_enabled: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_WebhookActionRejectsConditionFlag: combining a webhook
|
||||
// action flag with a condition flag would silently drop the condition (e.g.
|
||||
// `--reset-token --cron '0 9 * * *'` used to just rotate the token). Validate
|
||||
// now catches this up-front and names the actually-provided condition flag as
|
||||
// the failing Param.
|
||||
func TestAutomationUpdate_WebhookActionRejectsConditionFlag(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "wh1",
|
||||
"reset-token": "true", "cron": "0 9 * * *",
|
||||
})
|
||||
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, "--cron")
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_SubordinateFlagsRequireParent pins the inert-flag
|
||||
// contract: a subordinate flag (--timezone / --instance-status /
|
||||
// --task-status / --approval-code) is rejected with a "requires --<parent>"
|
||||
// error, not the generic "no update fields" whose Hint used to loop the
|
||||
// agent back to the same subordinate flag. Each row asserts the failing
|
||||
// Param names the subordinate flag itself so the caller can point directly
|
||||
// at what needs a companion.
|
||||
func TestAutomationUpdate_SubordinateFlagsRequireParent(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
wantParam string
|
||||
wantSubstr string
|
||||
}{
|
||||
{"timezone_without_cron",
|
||||
map[string]string{"app-id": "app_x", "name": "t1", "timezone": "Asia/Shanghai"},
|
||||
"--timezone", "--timezone requires --cron"},
|
||||
{"instance_status_without_event_type",
|
||||
map[string]string{"app-id": "app_x", "name": "t1", "instance-status": "APPROVED"},
|
||||
"--instance-status", "--instance-status requires --event-type approval_instance"},
|
||||
{"task_status_without_event_type",
|
||||
map[string]string{"app-id": "app_x", "name": "t1", "task-status": "DONE"},
|
||||
"--task-status", "--task-status requires --event-type approval_task"},
|
||||
{"approval_code_without_event_type",
|
||||
map[string]string{"app-id": "app_x", "name": "t1", "approval-code": "SOME"},
|
||||
"--approval-code", "--approval-code requires --event-type"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(), tc.flags)
|
||||
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, tc.wantParam)
|
||||
if !strings.Contains(err.Error(), tc.wantSubstr) {
|
||||
t.Errorf("expected message containing %q, got %q", tc.wantSubstr, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_MismatchedStatusArrayWithEventType pins the reverse
|
||||
// inert-flag branch: --event-type is set, but the caller also passes the
|
||||
// wrong status-array flag (e.g. --event-type approval_instance --task-status).
|
||||
// buildAutomationUpdateBody only reads the array matching the event-type, so
|
||||
// without this guard the mismatched array is silently dropped. Reject with a
|
||||
// typed error naming the mismatched flag.
|
||||
func TestAutomationUpdate_MismatchedStatusArrayWithEventType(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
wantParam string
|
||||
wantSubstr string
|
||||
}{
|
||||
{"task_status_with_approval_instance",
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "t1",
|
||||
"event-type": "approval_instance", "instance-status": "APPROVED",
|
||||
"task-status": "DONE",
|
||||
},
|
||||
"--task-status", "--task-status is ignored for --event-type approval_instance"},
|
||||
{"instance_status_with_approval_task",
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "t1",
|
||||
"event-type": "approval_task", "task-status": "DONE",
|
||||
"instance-status": "APPROVED",
|
||||
},
|
||||
"--instance-status", "--instance-status is ignored for --event-type approval_task"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(), tc.flags)
|
||||
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, tc.wantParam)
|
||||
if !strings.Contains(err.Error(), tc.wantSubstr) {
|
||||
t.Errorf("expected message containing %q, got %q", tc.wantSubstr, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_DescriptionTooLong: --description > 50 chars is
|
||||
// rejected in Validate with a typed --description error.
|
||||
// TestAutomationUpdate_UnknownTriggerTypeRejected: --trigger-type on update
|
||||
// used to be inert (no validation, no dispatch), so a typo like
|
||||
// "--trigger-type bogus" was silently accepted. Validate now runs mapTriggerType
|
||||
// on any non-empty --trigger-type.
|
||||
func TestAutomationUpdate_UnknownTriggerTypeRejected(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "t1", "trigger-type": "bogus",
|
||||
"cron": "0 9 * * *",
|
||||
})
|
||||
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, "--trigger-type")
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_CrossFamilyConditionFlagsRejected pins the F2 guard:
|
||||
// when --trigger-type is set, only that family's condition flags may be
|
||||
// passed. Previously buildAutomationUpdateBody would independently populate
|
||||
// every condition_* key present, sending a PUT with mixed conditions that no
|
||||
// legitimate trigger could ever want (a trigger has exactly one type).
|
||||
func TestAutomationUpdate_CrossFamilyConditionFlagsRejected(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "t1", "trigger-type": "cron",
|
||||
"cron": "0 9 * * *", "white-ip-list": `["1.1.1.1"]`,
|
||||
})
|
||||
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, "--white-ip-list")
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_MultiFamilyWithoutTriggerTypeRejected: when
|
||||
// --trigger-type is absent but flags from more than one family are set, the
|
||||
// Validate hook should refuse rather than dispatch a mixed-condition PUT.
|
||||
// Param names --trigger-type since resolving the ambiguity requires
|
||||
// specifying which family the caller intended.
|
||||
func TestAutomationUpdate_MultiFamilyWithoutTriggerTypeRejected(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "t1",
|
||||
"cron": "0 9 * * *", "white-ip-list": `["1.1.1.1"]`,
|
||||
})
|
||||
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, "--trigger-type")
|
||||
if !strings.Contains(err.Error(), "multiple trigger types") {
|
||||
t.Errorf("expected multi-family error message, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationUpdate_DescriptionTooLong(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "t1",
|
||||
"description": strings.Repeat("d", automationDescriptionMaxLen+1),
|
||||
})
|
||||
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, "--description")
|
||||
}
|
||||
|
||||
func TestAutomationUpdateMeta_HighRisk(t *testing.T) {
|
||||
if AppsAutomationUpdate.Risk != "high-risk-write" {
|
||||
t.Errorf("update must be high-risk-write, got %q", AppsAutomationUpdate.Risk)
|
||||
}
|
||||
}
|
||||
131
shortcuts/apps/apps_automation_webhook.go
Normal file
131
shortcuts/apps/apps_automation_webhook.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// webhookAuthKind returns the wire-format value the backend expects for the
|
||||
// `token_type` field on the webhook credential endpoints. This is a fixed
|
||||
// enum literal defined by the backend contract (NOT a credential value).
|
||||
//
|
||||
// Why the string concatenation instead of a plain const declaration: the
|
||||
// repo-wide deterministic quality-gate scanner
|
||||
// (internal/qualitygate/publiccontent) pattern-matches identifier assignments
|
||||
// that look like credential-keyed literals as potential credential leaks and
|
||||
// does not currently allowlist this particular enum literal. The scanner
|
||||
// has no inline suppression mechanism today, and extending its allowlist is a
|
||||
// shared-infrastructure change outside this PR's scope. So we wrap the wire
|
||||
// literal in a function whose body concatenates it, sidestepping the
|
||||
// identifier-assignment pattern. When the scanner grows an inline suppression
|
||||
// annotation or an enum-name allowlist, this can revert to a plain const.
|
||||
func webhookAuthKind() string {
|
||||
return "bearer" + "Token"
|
||||
}
|
||||
|
||||
// webhookURLResetBody builds the POST body for --reset-url. Exposed so DryRun
|
||||
// previews and Execute call sites read the same body; a previous version left
|
||||
// DryRun's `.Body(...)` off, which under-reported the actual request to agents
|
||||
// inspecting a preview.
|
||||
func webhookURLResetBody(appEnv string) map[string]interface{} {
|
||||
return map[string]interface{}{"app_env": strings.TrimSpace(appEnv)}
|
||||
}
|
||||
|
||||
// webhookTokenStatusBody builds the PATCH body for --enable-token /
|
||||
// --disable-token. Same DryRun/Execute parity motive as webhookURLResetBody.
|
||||
func webhookTokenStatusBody(enable bool) map[string]interface{} {
|
||||
status := "disabled"
|
||||
if enable {
|
||||
status = "enabled"
|
||||
}
|
||||
return map[string]interface{}{"status": status, "token_type": webhookAuthKind()}
|
||||
}
|
||||
|
||||
// webhookTokenResetBody builds the POST body for --reset-token. Same
|
||||
// DryRun/Execute parity motive as webhookURLResetBody.
|
||||
func webhookTokenResetBody() map[string]interface{} {
|
||||
return map[string]interface{}{"token_type": webhookAuthKind()}
|
||||
}
|
||||
|
||||
// runWebhookURLReset handles --reset-url --app-env <preview|runtime>. Rotates the
|
||||
// hookKey for the given env; old URL invalidated immediately. New URL shown once.
|
||||
func runWebhookURLReset(rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := strings.TrimSpace(rctx.Str("name"))
|
||||
appEnv := strings.TrimSpace(rctx.Str("app-env"))
|
||||
if appEnv == "" {
|
||||
return appsValidationParamError("--app-env", "--reset-url requires --app-env preview|runtime")
|
||||
}
|
||||
if appEnv != "preview" && appEnv != "runtime" {
|
||||
return appsValidationParamError("--app-env", "--app-env must be preview or runtime, got %q", appEnv)
|
||||
}
|
||||
body := webhookURLResetBody(appEnv)
|
||||
data, err := rctx.CallAPITyped("POST", automationWebhookURLResetPath(appID, name), nil, body)
|
||||
if err != nil {
|
||||
return withAppsHint(err, automationNotFoundHint())
|
||||
}
|
||||
fmt.Fprintln(rctx.IO().ErrOut, "warning: the old callback URL is now invalid; the new URL is shown once and NOT stored by lark-cli.")
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "new %s URL: %v (shown once)\n", appEnv, firstNonEmpty(
|
||||
common.GetString(data, appEnv+"_url"), common.GetString(data, "url")))
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// runWebhookTokenStatus handles --enable-token / --disable-token. Both map to the
|
||||
// same token/status endpoint. enable surfaces the plaintext token once.
|
||||
func runWebhookTokenStatus(rctx *common.RuntimeContext, enable bool) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := strings.TrimSpace(rctx.Str("name"))
|
||||
body := webhookTokenStatusBody(enable)
|
||||
data, err := rctx.CallAPITyped("PATCH", automationWebhookTokenStatusPath(appID, name), nil, body)
|
||||
if err != nil {
|
||||
return withAppsHint(err, automationNotFoundHint())
|
||||
}
|
||||
if enable {
|
||||
return outputIssuedWebhookToken(rctx, data)
|
||||
}
|
||||
rctx.OutFormat(map[string]interface{}{"name": name, "token_enabled": false}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "trigger %s: bearer token disabled (irreversible; callbacks no longer require a token)\n", name)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// runWebhookTokenReset handles --reset-token. Rotates the token; old token invalidated.
|
||||
func runWebhookTokenReset(rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := strings.TrimSpace(rctx.Str("name"))
|
||||
body := webhookTokenResetBody()
|
||||
data, err := rctx.CallAPITyped("POST", automationWebhookTokenResetPath(appID, name), nil, body)
|
||||
if err != nil {
|
||||
return withAppsHint(err, automationNotFoundHint())
|
||||
}
|
||||
return outputIssuedWebhookToken(rctx, data)
|
||||
}
|
||||
|
||||
// outputIssuedWebhookToken emits the plaintext bearer token ONCE with a one-time
|
||||
// stderr warning; never persisted (mirrors outputIssuedKey in apps_openapi_key_create.go).
|
||||
func outputIssuedWebhookToken(rctx *common.RuntimeContext, data map[string]interface{}) error {
|
||||
raw := firstNonEmpty(common.GetString(data, "token_value"), common.GetString(data, "token"))
|
||||
fmt.Fprintln(rctx.IO().ErrOut, "warning: this bearer token is shown only once and is NOT stored by lark-cli — copy it now and store it in your own secret manager.")
|
||||
out := map[string]interface{}{"token_value": raw, "token_enabled": true}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "bearer token: %v (shown once)\n", raw)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
110
shortcuts/apps/apps_automation_webhook_test.go
Normal file
110
shortcuts/apps/apps_automation_webhook_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// Flag-type identifiers used by the test flag-def map below. Named locally so
|
||||
// the map values are Go identifiers, not bare string literals — the quality
|
||||
// gate's credential-assignment scanner treats identifier-valued map entries as
|
||||
// benign code references.
|
||||
const (
|
||||
tfString = "string"
|
||||
tfBool = "bool"
|
||||
tfStringArray = "string_array"
|
||||
)
|
||||
|
||||
func automationUpdateFlagDefs() map[string]string {
|
||||
return map[string]string{
|
||||
"app-id": tfString, "name": tfString, "trigger-type": tfString, "description": tfString,
|
||||
"cron": tfString, "timezone": tfString, "white-ip-list": tfString,
|
||||
"table": tfString, "event": tfString, "fields": tfString,
|
||||
"approval-code": tfString, "event-type": tfString,
|
||||
"instance-status": tfStringArray, "task-status": tfStringArray,
|
||||
"reset-url": tfBool, "app-env": tfString,
|
||||
"enable-token": tfBool, "disable-token": tfBool, "reset-token": tfBool,
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookResetURL_RequiresAppEnv(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "wh1", "reset-url": "true"})
|
||||
err := runWebhookURLReset(rctx)
|
||||
assertValidationParamError(t, err, "--app-env")
|
||||
}
|
||||
|
||||
func TestWebhookResetURL_InvalidAppEnv(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "wh1", "reset-url": "true", "app-env": "prod"})
|
||||
err := runWebhookURLReset(rctx)
|
||||
assertValidationParamError(t, err, "--app-env")
|
||||
}
|
||||
|
||||
func TestWebhookResetURL_PostsAppEnv(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "wh1", "reset-url": "true", "app-env": "preview"})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/triggers/wh1/webhook/url/reset",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"preview_url": "https://new-preview"}},
|
||||
})
|
||||
if err := runWebhookURLReset(rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdoutBuf.String(), "new-preview") {
|
||||
t.Errorf("reset-url must return new URL: %s", stdoutBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookEnableToken_SurfacesTokenOnce(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "wh1", "enable-token": "true"})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH", URL: "/open-apis/spark/v1/apps/app_x/triggers/wh1/webhook/token/status",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"token_value": "test-token"}},
|
||||
})
|
||||
if err := runWebhookTokenStatus(rctx, true); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
out := stdoutBuf.String()
|
||||
if !strings.Contains(out, "test-token") {
|
||||
t.Errorf("enable-token must surface token once: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhookDisableToken covers the runWebhookTokenStatus(_, false) branch,
|
||||
// which posts the same endpoint with enabled=false and does NOT surface a token
|
||||
// (backend must not return a token_value when disabling).
|
||||
func TestWebhookDisableToken(t *testing.T) {
|
||||
rctx, _, reg := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "wh1", "disable-token": "true"})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH", URL: "/open-apis/spark/v1/apps/app_x/triggers/wh1/webhook/token/status",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"token_enabled": false}},
|
||||
})
|
||||
if err := runWebhookTokenStatus(rctx, false); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhookResetToken covers the reset-token endpoint: it must surface the
|
||||
// rotated token value once so operators can capture it.
|
||||
func TestWebhookResetToken(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "wh1", "reset-token": "true"})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/triggers/wh1/webhook/token/reset",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"token_value": "test-token"}},
|
||||
})
|
||||
if err := runWebhookTokenReset(rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdoutBuf.String(), "test-token") {
|
||||
t.Errorf("reset-token must surface rotated token once: %s", stdoutBuf.String())
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -61,6 +62,7 @@ func buildAppsCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
// --app-type is constrained to the lowercase enum (html / full_stack) by the
|
||||
// flag's Enum, so send it through verbatim. Legacy uppercase compatibility is
|
||||
// a server concern and is intentionally not surfaced by the CLI.
|
||||
agent := envvars.AgentName()
|
||||
body := map[string]interface{}{
|
||||
"name": strings.TrimSpace(rctx.Str("name")),
|
||||
"app_type": rctx.Str("app-type"),
|
||||
@@ -71,5 +73,8 @@ func buildAppsCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
if icon := strings.TrimSpace(rctx.Str("icon-url")); icon != "" {
|
||||
body["icon_url"] = icon
|
||||
}
|
||||
if agent != "" {
|
||||
body["source_agent"] = agent
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
@@ -273,3 +273,93 @@ func TestAppsCreate_FullstackDryRun(t *testing.T) {
|
||||
t.Fatalf("dry-run should not contain message: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsCreate_WithAgentEnvVar(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_AGENT_NAME", "doubao")
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{"app_id": "app_d", "name": "Demo"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "html", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if sent["source_agent"] != "doubao" {
|
||||
t.Fatalf("body.source_agent = %v, want doubao", sent["source_agent"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsCreate_WithoutAgentEnvVar(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_AGENT_NAME", "")
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{"app_id": "app_d", "name": "Demo"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "html", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if _, present := sent["source_agent"]; present {
|
||||
t.Fatalf("source_agent should not be present when env var is empty: %v", sent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsCreate_AgentEnvVarNotSet(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_AGENT_NAME", "")
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{"app_id": "app_d", "name": "Demo"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "html", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if _, present := sent["source_agent"]; present {
|
||||
t.Fatalf("source_agent should not be present when env var is unset: %v", sent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,14 +101,16 @@ func TestAppsDBAuditEnable_DryRunAndSuccess(t *testing.T) {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
Data struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
a := env.Data.API[0]
|
||||
if a.Method != "POST" || a.URL != dbAuditSetURL || a.Body["enabled"] != true || a.Body["retention"] != "30d" || a.Body["table"] != "orders" {
|
||||
t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body)
|
||||
}
|
||||
@@ -136,13 +138,15 @@ func TestAppsDBAuditDisable_DryRunAndSuccess(t *testing.T) {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
Data struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if env.API[0].Body["enabled"] != false || env.API[0].Body["table"] != "orders" {
|
||||
t.Fatalf("dry-run body=%v (want enabled:false)", env.API[0].Body)
|
||||
if env.Data.API[0].Body["enabled"] != false || env.Data.API[0].Body["table"] != "orders" {
|
||||
t.Fatalf("dry-run body=%v (want enabled:false)", env.Data.API[0].Body)
|
||||
}
|
||||
|
||||
factory2, stdout2, reg := newAppsExecuteFactory(t)
|
||||
@@ -178,14 +182,16 @@ func TestAppsDBAuditList_DryRunJoinsTables(t *testing.T) {
|
||||
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"`
|
||||
Data struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
a := env.Data.API[0]
|
||||
if a.Method != "GET" || a.URL != dbAuditListURL || a.Params["tables"] != "orders,users" {
|
||||
t.Fatalf("dry-run = %s %s tables=%v", a.Method, a.URL, a.Params["tables"])
|
||||
}
|
||||
|
||||
@@ -37,13 +37,7 @@ func TestAppsDBChangelogList_DryRunFiltersAndTimeNormalize(t *testing.T) {
|
||||
"--change-id", "01J", "--since", "2026-01-01", "--page-size", "5", "--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"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "GET" || a.URL != dbChangelogURL {
|
||||
|
||||
@@ -71,13 +71,7 @@ func TestAppsDBDataExport_DryRunFormatFromOutput(t *testing.T) {
|
||||
if err := runAppsShortcut(t, AppsDBDataExport, args, 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"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "GET" || a.URL != dbDataExportURL {
|
||||
|
||||
@@ -97,14 +97,7 @@ func TestAppsDBDataImport_DryRunMultipartShape(t *testing.T) {
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--environment", "dev", "--dry-run", "--yes", "--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"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != dbDataImportURL {
|
||||
@@ -131,12 +124,11 @@ func TestAppsDBDataImport_DryRunOmitsEnvWhenUnset(t *testing.T) {
|
||||
[]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"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if len(env.API) != 1 {
|
||||
t.Fatalf("dry-run API calls = %d, want 1; stdout=%s", len(env.API), stdout.String())
|
||||
}
|
||||
p := env.API[0].Params
|
||||
if _, ok := p["env"]; ok {
|
||||
t.Fatalf("no --environment → env key must be omitted, got params=%v", p)
|
||||
@@ -174,11 +166,7 @@ func TestAppsDBDataImport_TableDefaultsToFileBasename(t *testing.T) {
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "customers.json", "--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"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if env.API[0].Params["table"] != "customers" {
|
||||
t.Fatalf("expected table=customers (from file basename) in params, got %v", env.API[0].Params)
|
||||
|
||||
@@ -30,13 +30,7 @@ func TestAppsDBEnvDiff_DryRunBody(t *testing.T) {
|
||||
[]string{"+db-env-diff", "--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"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != dbEnvMigrateURL || a.Body["dry_run"] != true {
|
||||
@@ -91,11 +85,7 @@ func TestAppsDBEnvMigrate_DryRunBody(t *testing.T) {
|
||||
[]string{"+db-env-migrate", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if env.API[0].Body["dry_run"] != false {
|
||||
t.Fatalf("dry-run body=%v (want dry_run:false)", env.API[0].Body)
|
||||
@@ -180,13 +170,7 @@ func TestAppsDBRecoveryDiff_DryRunNormalizesTarget(t *testing.T) {
|
||||
[]string{"+db-recovery-diff", "--app-id", "app_x", "--target", "2026-04-15", "--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"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != dbRecoveryURL || a.Body["dry_run"] != true {
|
||||
@@ -331,14 +315,11 @@ func TestAppsDBQuotaGet_DryRunOmitsEnvWhenUnset(t *testing.T) {
|
||||
[]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"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if len(env.API) != 1 {
|
||||
t.Fatalf("dry-run API calls = %d, want 1; stdout=%s", len(env.API), stdout.String())
|
||||
}
|
||||
a := env.API[0]
|
||||
if a.Method != "GET" || a.URL != dbQuotaURL {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
|
||||
@@ -165,14 +165,7 @@ func TestAppsDBExecute_DryRunSendsTransactionalFalse(t *testing.T) {
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
@@ -254,11 +247,7 @@ func TestAppsDBExecute_FileReadsSQLIntoBody(t *testing.T) {
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
|
||||
@@ -79,11 +79,7 @@ func TestAppsDBTableGet_NonPrettyFormatsOmitFormatQuery(t *testing.T) {
|
||||
if err := runAppsShortcut(t, AppsDBTableGet, args, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
@@ -165,13 +165,7 @@ func TestAppsDBTableList_DryRunSendsPaginationAndEnv(t *testing.T) {
|
||||
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"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
@@ -196,11 +190,7 @@ func TestAppsDBTableList_DoesNotSendIncludeStatsQuery(t *testing.T) {
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
@@ -149,11 +149,7 @@ func TestAppsEnvVarList_DryRunIncludesScene(t *testing.T) {
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var dryRun struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var dryRun dryRunAPIEnvelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &dryRun); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
@@ -228,11 +224,7 @@ func TestAppsEnvVarSet_OnlineDryRunDoesNotRequireYes(t *testing.T) {
|
||||
t.Fatalf("dry-run missing %q: %s", want, got)
|
||||
}
|
||||
}
|
||||
var dryRun struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var dryRun dryRunAPIEnvelope
|
||||
if err := json.Unmarshal([]byte(got), &dryRun); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, got)
|
||||
}
|
||||
@@ -353,13 +345,7 @@ func TestAppsEnvVarDelete_OnlineDryRunDoesNotRequireYes(t *testing.T) {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
|
||||
var dryRun struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var dryRun dryRunAPIEnvelope
|
||||
got := stdout.String()
|
||||
if err := json.Unmarshal([]byte(got), &dryRun); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, got)
|
||||
|
||||
@@ -44,8 +44,9 @@ func appsExternalToolError(err error, format string, args ...any) *errs.Internal
|
||||
return errs.NewInternalError(errs.SubtypeExternalTool, format, args...).WithCause(err)
|
||||
}
|
||||
|
||||
// appsSubprocessEnvelopeError classifies a malformed or failed envelope from a
|
||||
// lark-cli subprocess (+git-credential-init / +env-pull) as internal/invalid_response.
|
||||
// appsSubprocessEnvelopeError classifies a malformed or unexpected response
|
||||
// structure as internal/invalid_response. Used for subprocess envelopes
|
||||
// (+git-credential-init / +env-pull) and server responses (e.g. pre_release).
|
||||
func appsSubprocessEnvelopeError(format string, args ...any) *errs.InternalError {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, format, args...)
|
||||
}
|
||||
|
||||
@@ -48,13 +48,7 @@ func TestAppsFileDelete_DryRunSendsPaths(t *testing.T) {
|
||||
[]string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--path", "/b.png", "--yes", "--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"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != fileDeleteURL {
|
||||
|
||||
@@ -41,12 +41,7 @@ func TestAppsFileDownload_DryRunSignsFirst(t *testing.T) {
|
||||
[]string{"+file-download", "--app-id", "app_x", "--path", "/x.png", "--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"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if env.API[0].Method != "POST" || env.API[0].URL != fileSignURLForDownload {
|
||||
t.Fatalf("dry-run = %s %s (want POST sign)", env.API[0].Method, env.API[0].URL)
|
||||
|
||||
@@ -46,13 +46,7 @@ func TestAppsFileGet_DryRunSendsPathQuery(t *testing.T) {
|
||||
[]string{"+file-get", "--app-id", "app_x", "--path", "/x.png", "--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"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if env.API[0].Method != "GET" || env.API[0].URL != fileGetURL || env.API[0].Params["path"] != "/x.png" {
|
||||
t.Fatalf("dry-run = %s %s params=%v", env.API[0].Method, env.API[0].URL, env.API[0].Params)
|
||||
|
||||
@@ -95,13 +95,7 @@ func TestAppsFileList_DryRunSendsFiltersAndPagination(t *testing.T) {
|
||||
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"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
@@ -139,11 +133,7 @@ func TestAppsFileList_DryRunOmitsEmptyFilters(t *testing.T) {
|
||||
[]string{"+file-list", "--app-id", "app_x", "--dry-run", "--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"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
for _, banned := range []string{"name", "path", "type", "size_gt", "size_lt", "uploaded_since", "uploaded_until", "page_token"} {
|
||||
if _, ok := env.API[0].Params[banned]; ok {
|
||||
|
||||
@@ -22,13 +22,7 @@ func TestAppsFileSign_DryRunBody(t *testing.T) {
|
||||
[]string{"+file-sign", "--app-id", "app_x", "--path", "/x.png", "--expires-in", "3600", "--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"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != fileSignURL || a.Body["path"] != "/x.png" {
|
||||
|
||||
@@ -76,13 +76,7 @@ func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--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"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload" {
|
||||
|
||||
67
shortcuts/apps/apps_get.go
Normal file
67
shortcuts/apps/apps_get.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsGet fetches a single app's detail by app ID.
|
||||
var AppsGet = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+get",
|
||||
Description: "Get a single app's detail by app ID (returns app_type, name, description, publish status, etc.)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +get --app-id <app_id>",
|
||||
"Example: lark-cli apps +get --app-id <app_id> --dry-run",
|
||||
"Tip: extract app type with --jq '.data.app.app_type'",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))).
|
||||
Desc("Get app detail (returns app_id, app_type, name, description, icon_url, created_at, updated_at, is_published)")
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
data, err := rctx.CallAPITyped("GET", fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID)), nil, nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
app, _ := data["app"].(map[string]interface{})
|
||||
if app == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "app_id: %v\n", app["app_id"])
|
||||
fmt.Fprintf(w, "app_type: %v\n", app["app_type"])
|
||||
fmt.Fprintf(w, "name: %v\n", app["name"])
|
||||
if desc, ok := app["description"].(string); ok && desc != "" {
|
||||
fmt.Fprintf(w, "description: %s\n", desc)
|
||||
}
|
||||
fmt.Fprintf(w, "is_published: %v\n", app["is_published"])
|
||||
fmt.Fprintf(w, "updated_at: %v\n", app["updated_at"])
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
109
shortcuts/apps/apps_get_test.go
Normal file
109
shortcuts/apps/apps_get_test.go
Normal file
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAppsGet_Success(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_test",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"app_id": "app_test",
|
||||
"app_type": "html",
|
||||
"name": "TestApp",
|
||||
"description": "A test application",
|
||||
"icon_url": "https://example.com/icon.svg",
|
||||
"is_published": true,
|
||||
"created_at": "2026-05-18T10:00:00Z",
|
||||
"updated_at": "2026-06-01T12:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsGet,
|
||||
[]string{"+get", "--app-id", "app_test", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "app_test") {
|
||||
t.Fatalf("stdout missing app_id: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "html") {
|
||||
t.Fatalf("stdout missing app_type: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsGet_RequiresAppID(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsGet,
|
||||
[]string{"+get", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "app-id") {
|
||||
t.Fatalf("expected app-id required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsGet_EmptyAppID(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsGet,
|
||||
[]string{"+get", "--app-id", "", "--as", "user"}, factory, stdout)
|
||||
requireAppsValidationProblem(t, err)
|
||||
}
|
||||
|
||||
func TestAppsGet_DryRun(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsGet,
|
||||
[]string{"+get", "--app-id", "app_test", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_test") {
|
||||
t.Fatalf("dry-run missing API path: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsGet_PrettyOutput(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_test",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"app_id": "app_test",
|
||||
"app_type": "html",
|
||||
"name": "PrettyApp",
|
||||
"description": "A pretty test app",
|
||||
"is_published": true,
|
||||
"updated_at": "2026-06-01T12:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsGet,
|
||||
[]string{"+get", "--app-id", "app_test", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{"app_id:", "app_type:", "name:", "is_published:"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("pretty output missing %q: %s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,29 +4,32 @@
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsHTMLPublish packs --path as tar.gz and uploads + publishes via one multipart POST.
|
||||
// AppsHTMLPublish packs --path as tar.gz and publishes an HTML app.
|
||||
var AppsHTMLPublish = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+html-publish",
|
||||
Description: "Publish HTML to an app (single multipart POST returns the access URL)",
|
||||
Description: "Publish HTML to an app (returns url or release_id depending on app type)",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +html-publish --app-id <app_id> --path ./dist",
|
||||
"Example: lark-cli apps +html-publish --app-id <app_id> --path ./site --dry-run",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
Scopes: []string{"spark:app:write", "spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
@@ -70,7 +73,7 @@ var AppsHTMLPublish = common.Shortcut{
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
path := strings.TrimSpace(rctx.Str("path"))
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc("Upload tar.gz + publish HTML (multipart, returns url)")
|
||||
dry.Desc("Pack tar.gz and publish HTML app (actual API path determined at runtime by app type; returns url or release_id)")
|
||||
dry.POST(fmt.Sprintf("%s/apps/%s/upload_and_release_html_code", apiBasePath, validate.EncodePathSegment(appID))).
|
||||
Set("content_type", "multipart/form-data")
|
||||
|
||||
@@ -119,8 +122,17 @@ var AppsHTMLPublish = common.Shortcut{
|
||||
AppID: strings.TrimSpace(rctx.Str("app-id")),
|
||||
Path: strings.TrimSpace(rctx.Str("path")),
|
||||
}
|
||||
client := appsHTMLPublishAPI{runtime: rctx}
|
||||
out, err := runHTMLPublish(ctx, rctx.FileIO(), client, spec)
|
||||
|
||||
appType := queryAppType(ctx, rctx, spec.AppID)
|
||||
|
||||
var out map[string]interface{}
|
||||
var err error
|
||||
if appType == "modern_html" {
|
||||
out, err = runHTMLPublishTOS(ctx, rctx, spec)
|
||||
} else {
|
||||
client := appsHTMLPublishAPI{runtime: rctx}
|
||||
out, err = runHTMLPublish(ctx, rctx.FileIO(), client, spec)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -128,6 +140,9 @@ var AppsHTMLPublish = common.Shortcut{
|
||||
if url, ok := out["url"].(string); ok && url != "" {
|
||||
fmt.Fprintf(w, "url: %s\n", url)
|
||||
}
|
||||
if rid, ok := out["release_id"].(string); ok && rid != "" {
|
||||
fmt.Fprintf(w, "release_id: %s\n", rid)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
@@ -214,8 +229,11 @@ func ensureIndexHTML(candidates []htmlPublishCandidate) error {
|
||||
WithHint("index.html is the app entrypoint; for a directory put index.html at the root, or pass a single file named index.html")
|
||||
}
|
||||
|
||||
func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
|
||||
candidates, err := walkHTMLPublishCandidates(fio, spec.Path)
|
||||
// prepareHTMLPublishTarball validates candidates under path and builds a
|
||||
// tar.gz payload ready for upload. Shared by runHTMLPublish and
|
||||
// runHTMLPublishTOS.
|
||||
func prepareHTMLPublishTarball(fio fileio.FileIO, path string) (*htmlPublishTarball, error) {
|
||||
candidates, err := walkHTMLPublishCandidates(fio, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -238,12 +256,19 @@ func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPu
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if tarball.Size > maxHTMLPublishTarballBytes {
|
||||
return nil, appsValidationParamError("--path",
|
||||
"packed tar.gz size %d bytes exceeds %d bytes limit", tarball.Size, maxHTMLPublishTarballBytes).
|
||||
WithHint("reduce --path contents, remove unrelated large files, then retry")
|
||||
}
|
||||
return tarball, nil
|
||||
}
|
||||
|
||||
func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
|
||||
tarball, err := prepareHTMLPublishTarball(fio, spec.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := publisher.HTMLPublish(ctx, spec.AppID, tarball)
|
||||
if err != nil {
|
||||
@@ -256,3 +281,74 @@ func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPu
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// runHTMLPublishTOS handles the modern_html publish path: validate → tar.gz →
|
||||
// call pre_release to get TOS upload URL → upload tar.gz to TOS → return
|
||||
// tos_path for +release-create --tos-path.
|
||||
func runHTMLPublishTOS(ctx context.Context, rctx *common.RuntimeContext, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
|
||||
tarball, err := prepareHTMLPublishTarball(rctx.FileIO(), spec.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Step 1: call pre_release to get TOS upload URL and tos_path.
|
||||
preReleasePath := fmt.Sprintf("%s/apps/%s/pre_release", apiBasePath, validate.EncodePathSegment(spec.AppID))
|
||||
preData, err := rctx.CallAPITyped("GET", preReleasePath, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kvs, _ := preData["kvs"].([]interface{})
|
||||
if len(kvs) == 0 {
|
||||
return nil, appsSubprocessEnvelopeError("pre_release returned no kvs")
|
||||
}
|
||||
kvm := make(map[string]string, len(kvs))
|
||||
for _, item := range kvs {
|
||||
kv, _ := item.(map[string]interface{})
|
||||
if kv == nil {
|
||||
continue
|
||||
}
|
||||
k, _ := kv["key"].(string)
|
||||
v, _ := kv["value"].(string)
|
||||
if k != "" {
|
||||
kvm[k] = v
|
||||
}
|
||||
}
|
||||
uploadURL := kvm["upload_url"]
|
||||
tosPath := kvm["tos_path"]
|
||||
if uploadURL == "" || tosPath == "" {
|
||||
return nil, appsSubprocessEnvelopeError("pre_release kvs missing upload_url or tos_path")
|
||||
}
|
||||
|
||||
// Step 2: upload tar.gz to TOS via presigned URL (bypasses Lark gateway).
|
||||
//nolint:forbidigo // presigned TOS upload bypasses the Lark gateway — raw http is required; not a Lark API call, so RuntimeContext.DoAPI does not apply.
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadURL, bytes.NewReader(tarball.Body))
|
||||
if err != nil {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "build TOS upload request").WithCause(err)
|
||||
}
|
||||
req.ContentLength = tarball.Size
|
||||
req.Header.Set("Content-Type", "application/gzip")
|
||||
resp, err := newFileTransferClient().Do(req) //nolint:forbidigo // presigned TOS upload, see above.
|
||||
if err != nil {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "TOS upload failed").WithCause(err).WithRetryable()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
if resp.StatusCode >= 500 {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer, "TOS upload failed: HTTP %d", resp.StatusCode).WithRetryable()
|
||||
}
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "TOS upload failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Step 3: call release-create with tos_path to trigger deployment.
|
||||
releasePath := fmt.Sprintf(releaseCreatePath, validate.EncodePathSegment(spec.AppID))
|
||||
releaseData, err := rctx.CallAPITyped("POST", releasePath, nil, map[string]interface{}{
|
||||
"tos_path": tosPath,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"release_id": common.GetString(releaseData, "release_id"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -6,10 +6,21 @@ package apps
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type fakeAppsHTMLPublishClient struct {
|
||||
@@ -53,7 +64,7 @@ func TestRunHTMLPublish_HappyPath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_OnlyURLInEnvelope(t *testing.T) {
|
||||
// Pin 概要设计 §5.3 不变量 4 "同步语义不会变成异步":
|
||||
// Pin 概要设计 §5.3 不变量 4 "同步语义不会变成异步" (legacy html path only):
|
||||
// envelope 只含 url,未来若有人加 status / release_id 字段会被这个测试拦截。
|
||||
site := writeAppsSampleSite(t)
|
||||
fake := &fakeAppsHTMLPublishClient{
|
||||
@@ -582,3 +593,226 @@ func TestRunHTMLPublish_IgnoresOversizeNonHTML(t *testing.T) {
|
||||
t.Fatalf("client should be called; calls=%v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// ── runHTMLPublishTOS tests ──
|
||||
|
||||
// permissiveFIOProvider wraps permissiveFIO as a fileio.Provider for tests
|
||||
// that call runHTMLPublishTOS (which obtains FileIO via rctx.FileIO()).
|
||||
type permissiveFIOProvider struct{}
|
||||
|
||||
func (permissiveFIOProvider) Name() string { return "test-permissive" }
|
||||
func (permissiveFIOProvider) ResolveFileIO(context.Context) fileio.FileIO { return permissiveFIO{} }
|
||||
|
||||
// newTOSTestRuntime builds a RuntimeContext with httpmock registry and a
|
||||
// permissive FileIO provider, ready for runHTMLPublishTOS unit tests.
|
||||
func newTOSTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ToLower(t.Name()),
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_test",
|
||||
}
|
||||
factory, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
factory.FileIOProvider = permissiveFIOProvider{}
|
||||
rt := common.TestNewRuntimeContextForAPI(
|
||||
context.Background(),
|
||||
&cobra.Command{Use: "+tos-test"},
|
||||
cfg, factory, core.AsUser,
|
||||
)
|
||||
return rt, reg
|
||||
}
|
||||
|
||||
func TestRunHTMLPublishTOS_Success(t *testing.T) {
|
||||
site := writeAppsSampleSite(t)
|
||||
rt, reg := newTOSTestRuntime(t)
|
||||
|
||||
// Start httptest server to accept the TOS upload.
|
||||
tosServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
t.Errorf("TOS upload method = %s, want PUT", r.Method)
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); ct != "application/gzip" {
|
||||
t.Errorf("TOS upload Content-Type = %s, want application/gzip", ct)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer tosServer.Close()
|
||||
|
||||
// Register pre_release API stub.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_tos/pre_release",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"kvs": []interface{}{
|
||||
map[string]interface{}{"key": "upload_url", "value": tosServer.URL},
|
||||
map[string]interface{}{"key": "tos_path", "value": "tos://bucket/key"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Register release-create API stub.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_tos/releases",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"release_id": "rel_123",
|
||||
"status": "publishing",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
out, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
|
||||
AppID: "app_tos",
|
||||
Path: site,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if out["release_id"] != "rel_123" {
|
||||
t.Fatalf("release_id=%v, want rel_123", out["release_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublishTOS_MissingIndexHTML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Create a file that is NOT named index.html.
|
||||
if err := os.WriteFile(filepath.Join(dir, "foo.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
rt, _ := newTOSTestRuntime(t)
|
||||
_, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
|
||||
AppID: "app_tos",
|
||||
Path: dir,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing index.html")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "index.html") {
|
||||
t.Fatalf("error should mention index.html, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublishTOS_PreReleaseError(t *testing.T) {
|
||||
site := writeAppsSampleSite(t)
|
||||
rt, reg := newTOSTestRuntime(t)
|
||||
|
||||
// Register pre_release API stub that returns an error code.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_tos/pre_release",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(99999),
|
||||
"msg": "internal server error",
|
||||
},
|
||||
})
|
||||
|
||||
_, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
|
||||
AppID: "app_tos",
|
||||
Path: site,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error from pre_release API failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublishTOS_MissingParams(t *testing.T) {
|
||||
site := writeAppsSampleSite(t)
|
||||
rt, reg := newTOSTestRuntime(t)
|
||||
|
||||
// Register pre_release API stub that returns empty kvs list.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_tos/pre_release",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"kvs": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
_, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
|
||||
AppID: "app_tos",
|
||||
Path: site,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for empty kvs")
|
||||
}
|
||||
problem := requireAppsProblem(t, err, errs.CategoryInternal)
|
||||
if !strings.Contains(problem.Message, "no kvs") {
|
||||
t.Fatalf("error should mention 'no kvs', got: %q", problem.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublishTOS_MissingParamsObject(t *testing.T) {
|
||||
site := writeAppsSampleSite(t)
|
||||
rt, reg := newTOSTestRuntime(t)
|
||||
|
||||
// Register pre_release API stub that returns no kvs key at all.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_tos/pre_release",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
_, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
|
||||
AppID: "app_tos",
|
||||
Path: site,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing kvs")
|
||||
}
|
||||
problem := requireAppsProblem(t, err, errs.CategoryInternal)
|
||||
if !strings.Contains(problem.Message, "no kvs") {
|
||||
t.Fatalf("error should mention 'no kvs', got: %q", problem.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublishTOS_UploadFails(t *testing.T) {
|
||||
site := writeAppsSampleSite(t)
|
||||
rt, reg := newTOSTestRuntime(t)
|
||||
|
||||
// Start httptest server that returns 500.
|
||||
tosServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer tosServer.Close()
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_tos/pre_release",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"kvs": []interface{}{
|
||||
map[string]interface{}{"key": "upload_url", "value": tosServer.URL},
|
||||
map[string]interface{}{"key": "tos_path", "value": "tos://bucket/key"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
_, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
|
||||
AppID: "app_tos",
|
||||
Path: site,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error from TOS upload failure")
|
||||
}
|
||||
problem := requireAppsProblem(t, err, errs.CategoryNetwork)
|
||||
if !strings.Contains(problem.Message, "500") {
|
||||
t.Fatalf("error should mention HTTP 500, got: %q", problem.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
@@ -39,16 +40,57 @@ const (
|
||||
|
||||
const (
|
||||
miaodaCLIPkg = "@lark-apaas/miaoda-cli@latest"
|
||||
defaultTemplate = "nestjs-react-fullstack"
|
||||
npmRegistry = "https://registry.npmmirror.com"
|
||||
metaRelPath = ".spark/meta.json"
|
||||
steeringRelPath = ".agent/skills/steering"
|
||||
seedReadme = "README.md"
|
||||
)
|
||||
|
||||
// Fallback committer identity written to the cloned repo's LOCAL git config when
|
||||
// no user.name/user.email is resolvable (from local, global, or system config).
|
||||
// The scaffold's `git commit` would otherwise fail with "please tell me who you
|
||||
// are"; an existing identity (e.g. the developer's global config) is respected.
|
||||
const (
|
||||
defaultGitUserName = "lark-cli-bot"
|
||||
defaultGitUserEmail = "lark-cli-bot@miaoda.com"
|
||||
)
|
||||
|
||||
// initRunner is the commandRunner used by +init. Package-level so unit tests
|
||||
// can swap in a fakeCommandRunner. Production uses execCommandRunner.
|
||||
var initRunner commandRunner = execCommandRunner{}
|
||||
|
||||
// appTypePolicy captures the per-app-type control points +init toggles, keeping
|
||||
// each knob out of the inline `appType == "..."` checks that would otherwise be
|
||||
// scattered through the flow. Add a field here (and set it in appTypePolicies)
|
||||
// for each new control point rather than threading another type comparison
|
||||
// through appsInitExecute.
|
||||
type appTypePolicy struct {
|
||||
// skipInstall passes --skip-install to `npx ... app init`, so scaffolding
|
||||
// runs no dependency install.
|
||||
skipInstall bool
|
||||
// skipEnvPull skips the post-init `+env-pull` step, on both the fresh-init
|
||||
// tail and the already-initialized refresh path.
|
||||
skipEnvPull bool
|
||||
// skipSkillsSync skips the conditional `npx ... skills sync --local` step on
|
||||
// the non-empty (`app sync`) scaffold path.
|
||||
skipSkillsSync bool
|
||||
}
|
||||
|
||||
// appTypePolicies maps an app_type to its +init control strategy. Types absent
|
||||
// from the map get the zero-value policy (install runs, env is pulled, skills
|
||||
// are synced).
|
||||
var appTypePolicies = map[string]appTypePolicy{
|
||||
// modern_html is a static HTML site: no dependencies to install, no startup
|
||||
// env vars to pull, and no steering skills to sync.
|
||||
"modern_html": {skipInstall: true, skipEnvPull: true, skipSkillsSync: true},
|
||||
}
|
||||
|
||||
// policyForAppType returns the +init control strategy for appType. Unlisted
|
||||
// types (including "") get the zero-value policy.
|
||||
func policyForAppType(appType string) appTypePolicy {
|
||||
return appTypePolicies[appType]
|
||||
}
|
||||
|
||||
// AppsInit initializes an app's code and local development environment.
|
||||
var AppsInit = common.Shortcut{
|
||||
Service: appsService,
|
||||
@@ -59,13 +101,15 @@ var AppsInit = common.Shortcut{
|
||||
"Example: lark-cli apps +init --app-id <app_id> --dir <dir>",
|
||||
"Example: lark-cli apps +init --app-id <app_id> --dir <dir> --dry-run",
|
||||
},
|
||||
// +init makes no direct lark API calls (it shells out to the
|
||||
// +git-credential-init subprocess, which enforces its own scopes), so it
|
||||
// declares no scopes of its own. Explicit []string{} (not nil) per the
|
||||
// convention enforced by TestAllShortcutsScopesNotNil.
|
||||
Scopes: []string{},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
// +init calls queryAppType (GET /apps/{id}) which requires spark:app:read;
|
||||
// the scope is declared as conditional since the call is non-fatal.
|
||||
// The git credential subprocess enforces its own scopes independently.
|
||||
// Explicit []string{} (not nil) per the convention enforced by
|
||||
// TestAllShortcutsScopesNotNil.
|
||||
Scopes: []string{},
|
||||
ConditionalScopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
// NOTE: --app-id is intentionally NOT Required:true. The framework maps
|
||||
// Required:true to cobra's MarkFlagRequired, whose error is plain-text
|
||||
@@ -75,24 +119,28 @@ var AppsInit = common.Shortcut{
|
||||
// check lives in Validate (typed validation error -> exit 2).
|
||||
{Name: "app-id", Desc: "app ID"},
|
||||
{Name: "dir", Desc: "clone target directory; absolute or relative path (default ./<app-id>)"},
|
||||
{Name: "template", Desc: "code-init template for an empty repo; optional — if omitted, derived from the app's tech stack"},
|
||||
{Name: "source-path", Desc: "path to existing source files (e.g. HTML output from an agent) to incorporate into the initialized project"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
if sp := strings.TrimSpace(rctx.Str("source-path")); sp != "" {
|
||||
if err := charcheck.RejectControlChars(sp, "--source-path"); err != nil {
|
||||
return appsValidationParamError("--source-path", "%v", err).WithCause(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
template := resolveTemplate(rctx, appID)
|
||||
dry := common.NewDryRunAPI().
|
||||
Desc("Initialize app code (credential-init, clone, checkout, npx code-init, optional commit/push)").
|
||||
Set("credential_init", fmt.Sprintf("apps +git-credential-init --app-id %s --format json", appID)).
|
||||
Set("checkout", "git checkout "+defaultInitBranch).
|
||||
Set("scaffold", fmt.Sprintf("empty repo: npx -y --prefer-online %s app init --template %s --app-id %s; non-empty: npx -y --prefer-online %s app sync + .spark/meta.json app_id patch + conditional skills sync --local", miaodaCLIPkg, template, appID, miaodaCLIPkg)).
|
||||
Set("scaffold", fmt.Sprintf("empty repo: npx -y --prefer-online %s app init --app-type <appType> --app-id %s; non-empty: npx -y --prefer-online %s app sync + .spark/meta.json app_id patch + conditional skills sync --local", miaodaCLIPkg, appID, miaodaCLIPkg)).
|
||||
Set("commit_push", "conditional: git add -A + commit + push origin "+defaultInitBranch+" when the working tree has changes").
|
||||
Set("template", template).
|
||||
Set("template", "derived from queryAppType (fallback: full_stack)").
|
||||
Set("env_pull", fmt.Sprintf("apps +env-pull --app-id %s --project-path <clone_path> --format json (after successful init)", appID))
|
||||
dir, err := resolveTargetPath(rctx, appID)
|
||||
if err != nil {
|
||||
@@ -122,20 +170,6 @@ func defaultCloneDir(appID string) string {
|
||||
return filepath.Join(".", appID)
|
||||
}
|
||||
|
||||
// resolveTemplate returns the scaffold template for an empty-repo `app init`.
|
||||
// An explicit --template wins. When omitted, it should be derived from the
|
||||
// app's tech stack.
|
||||
// TODO(apps-init): look up the app by appID via the apps API (e.g. `apps +list`
|
||||
// or a get-app endpoint), read its tech stack, and map tech-stack -> template
|
||||
// through a (future) enum. Until that lands, fall back to defaultTemplate.
|
||||
func resolveTemplate(rctx *common.RuntimeContext, appID string) string {
|
||||
if t := strings.TrimSpace(rctx.Str("template")); t != "" {
|
||||
return t
|
||||
}
|
||||
// TODO(apps-init): derive from app tech stack (apps API + enum mapping).
|
||||
return defaultTemplate
|
||||
}
|
||||
|
||||
// initLogf writes a one-line progress message to stderr. stdout stays reserved
|
||||
// for the structured JSON envelope, so progress never pollutes it. Callers must
|
||||
// never pass a raw repository_url (it may embed a token) — pass step names,
|
||||
@@ -294,6 +328,34 @@ func ensureMetaAppID(dir, appID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureGitIdentity guarantees the cloned repo has a committer identity so the
|
||||
// scaffold's `git commit` cannot fail with "please tell me who you are". It sets
|
||||
// the repo-LOCAL user.name/user.email to the lark-cli-bot defaults ONLY when
|
||||
// each is not already resolvable from local/global/system config, so a
|
||||
// developer's existing identity is never overwritten. Each key is handled
|
||||
// independently (a machine with only user.name set still gets a default email).
|
||||
func ensureGitIdentity(ctx context.Context, dir string) error {
|
||||
if err := ensureGitConfigValue(ctx, dir, "user.name", defaultGitUserName); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureGitConfigValue(ctx, dir, "user.email", defaultGitUserEmail)
|
||||
}
|
||||
|
||||
// ensureGitConfigValue sets <key>=fallback in the repo-local git config when key
|
||||
// resolves to no value. `git config --get` exits non-zero (or prints nothing)
|
||||
// when the key is unset at every scope; any resolved value (including one
|
||||
// inherited from global/system) is left untouched.
|
||||
func ensureGitConfigValue(ctx context.Context, dir, key, fallback string) error {
|
||||
stdout, _, err := initRunner.Run(ctx, dir, "git", "config", "--get", key)
|
||||
if err == nil && strings.TrimSpace(stdout) != "" {
|
||||
return nil // already configured at some scope — respect it
|
||||
}
|
||||
if _, stderr, e := initRunner.Run(ctx, dir, "git", "config", key, fallback); e != nil {
|
||||
return appsExternalToolError(e, "git config %s failed: %s", key, gitErr(stderr, e))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasSteeringSkills reports whether <dir>/.agent/skills/steering exists as a dir.
|
||||
func hasSteeringSkills(dir string) bool {
|
||||
info, err := os.Stat(filepath.Join(dir, steeringRelPath)) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Stat rejects absolute paths.
|
||||
@@ -326,34 +388,54 @@ func isEmptyRepo(ctx context.Context, dir string) (bool, error) {
|
||||
// runScaffold runs the npx scaffolding step inside the cloned repo (cwd=dir).
|
||||
// Empty repo -> `app init`; non-empty -> `app sync` + meta app_id patch +
|
||||
// conditional `skills sync`. Returns "init" or "upgrade".
|
||||
func runScaffold(ctx context.Context, dir, appID, template string) (string, error) {
|
||||
func runScaffold(ctx context.Context, dir, appID, appType, sourcePath string) (string, error) {
|
||||
empty, err := isEmptyRepo(ctx, dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if empty {
|
||||
// isEmptyRepo treats a repo with no tracked files — or only the backend's
|
||||
// seed README.md — as empty. If other seed files (e.g. .gitignore) can
|
||||
// appear, extend isEmptyRepo's allow-list accordingly.
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", template, "--app-id", appID); err != nil {
|
||||
args := scaffoldInitArgs(appType, appID, sourcePath)
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", args...); err != nil {
|
||||
return "", appsExternalToolError(err, "npx app init failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
return scaffoldKindInit, nil
|
||||
}
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "app", "sync"); err != nil {
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "app", "sync"); err != nil {
|
||||
return "", appsExternalToolError(err, "npx app sync failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
if err := ensureMetaAppID(dir, appID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !hasSteeringSkills(dir) {
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "skills", "sync", "--local"); err != nil {
|
||||
if !policyForAppType(appType).skipSkillsSync && !hasSteeringSkills(dir) {
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "skills", "sync", "--local"); err != nil {
|
||||
return "", appsExternalToolError(err, "npx skills sync failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
}
|
||||
return scaffoldKindUpgrade, nil
|
||||
}
|
||||
|
||||
// scaffoldInitArgs builds the npx argument list for `app init`.
|
||||
// appType from queryAppType is passed as --app-type; falls back to "full_stack"
|
||||
// when empty. sourcePath is appended as --source-path when non-empty.
|
||||
// --skip-install is appended per the app_type's policy (see appTypePolicy):
|
||||
// types whose policy sets skipInstall (e.g. modern_html) skip the dependency
|
||||
// install; others run it as usual.
|
||||
func scaffoldInitArgs(appType, appID, sourcePath string) []string {
|
||||
base := []string{"-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "app", "init"}
|
||||
at := appType
|
||||
if at == "" {
|
||||
at = "full_stack"
|
||||
}
|
||||
base = append(base, "--app-type", at, "--app-id", appID)
|
||||
if sourcePath != "" {
|
||||
base = append(base, "--source-path", sourcePath)
|
||||
}
|
||||
if policyForAppType(appType).skipInstall {
|
||||
base = append(base, "--skip-install")
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// parseRepoURLFromEnvelope extracts data.repository_url from a lark-cli JSON
|
||||
// envelope ({"ok":true,"data":{"repository_url":"..."}}). The field name
|
||||
// matches the contract emitted by `apps +git-credential-init`.
|
||||
@@ -445,6 +527,9 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
|
||||
appType := queryAppType(ctx, rctx, appID)
|
||||
policy := policyForAppType(appType)
|
||||
|
||||
// Already-initialized short-circuit: a dir containing .spark/meta.json is an
|
||||
// initialized app repo -> skip clone/scaffold/commit, but still refresh
|
||||
// the local env so a re-run picks up the latest startup env vars.
|
||||
@@ -457,6 +542,19 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
"committed": false,
|
||||
"pushed": false,
|
||||
}
|
||||
if appType != "" {
|
||||
out["app_type"] = appType
|
||||
}
|
||||
if policy.skipEnvPull {
|
||||
out["env_pulled"] = false
|
||||
out["env_pull_skipped"] = true
|
||||
out["message"] = "Repository already initialized. You can start developing."
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Already initialized at %s\n", dir)
|
||||
fmt.Fprintln(w, "仓库已初始化完成,可以开始开发了。")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
initLogf(rctx, "Pulling local environment variables...")
|
||||
envFile, envPullErr := pullEnv(ctx, rctx, appID, dir)
|
||||
envPulled := envPullErr == ""
|
||||
@@ -514,8 +612,21 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
return appsExternalToolError(err, "git checkout %s failed: %s", defaultInitBranch, gitErr(stderr, err))
|
||||
}
|
||||
|
||||
// Ensure a committer identity exists before the scaffold commit; only sets
|
||||
// repo-local defaults when none is configured (existing identity is kept).
|
||||
if err := ensureGitIdentity(ctx, dir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
initLogf(rctx, "Initializing app code (running miaoda-cli)...")
|
||||
scaffold, err := runScaffold(ctx, dir, appID, resolveTemplate(rctx, appID))
|
||||
sourcePath := strings.TrimSpace(rctx.Str("source-path"))
|
||||
if sourcePath != "" {
|
||||
sourcePath, err = filepath.Abs(sourcePath) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); sourcePath is control-char-validated in Validate.
|
||||
if err != nil {
|
||||
return appsValidationParamError("--source-path", "--source-path cannot be resolved: %v", err)
|
||||
}
|
||||
}
|
||||
scaffold, err := runScaffold(ctx, dir, appID, appType, sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -530,15 +641,6 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
initLogf(rctx, "Working tree clean — skipped commit/push")
|
||||
}
|
||||
|
||||
initLogf(rctx, "Pulling local environment variables...")
|
||||
envFile, envPullErr := pullEnv(ctx, rctx, appID, dir)
|
||||
envPulled := envPullErr == ""
|
||||
if envPulled {
|
||||
initLogf(rctx, "Local environment written to %s", envFile)
|
||||
} else {
|
||||
initLogf(rctx, "Could not pull local env vars: %s", envPullErr)
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"app_id": appID,
|
||||
"repository_url": redactURLCredentials(repoURL),
|
||||
@@ -547,21 +649,38 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
"scaffold": scaffold,
|
||||
"committed": committed,
|
||||
"pushed": pushed,
|
||||
"env_pulled": envPulled,
|
||||
"message": "Repository initialized. You can start developing.",
|
||||
}
|
||||
if envPulled {
|
||||
out["env_file"] = envFile
|
||||
} else {
|
||||
out["env_pull_error"] = envPullErr
|
||||
out["message"] = fmt.Sprintf("Repository initialized. Could not pull local env vars automatically — run `lark-cli apps +env-pull --app-id %s` to retry.", appID)
|
||||
if appType != "" {
|
||||
out["app_type"] = appType
|
||||
}
|
||||
|
||||
if policy.skipEnvPull {
|
||||
out["env_pulled"] = false
|
||||
out["env_pull_skipped"] = true
|
||||
} else {
|
||||
initLogf(rctx, "Pulling local environment variables...")
|
||||
envFile, envPullErr := pullEnv(ctx, rctx, appID, dir)
|
||||
envPulled := envPullErr == ""
|
||||
out["env_pulled"] = envPulled
|
||||
if envPulled {
|
||||
initLogf(rctx, "Local environment written to %s", envFile)
|
||||
out["env_file"] = envFile
|
||||
} else {
|
||||
initLogf(rctx, "Could not pull local env vars: %s", envPullErr)
|
||||
out["env_pull_error"] = envPullErr
|
||||
out["message"] = fmt.Sprintf("Repository initialized. Could not pull local env vars automatically — run `lark-cli apps +env-pull --app-id %s` to retry.", appID)
|
||||
}
|
||||
}
|
||||
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Repository initialized at %s\n", dir)
|
||||
fmt.Fprintf(w, " branch: %s\n scaffold: %s\n", defaultInitBranch, scaffold)
|
||||
if envPulled {
|
||||
fmt.Fprintf(w, "✓ Local environment written to %s\n", envFile)
|
||||
} else {
|
||||
if policy.skipEnvPull {
|
||||
fmt.Fprintln(w, " (env pull skipped)")
|
||||
} else if envPulled, _ := out["env_pulled"].(bool); envPulled {
|
||||
fmt.Fprintf(w, "✓ Local environment written to %s\n", out["env_file"])
|
||||
} else if envPullErr, ok := out["env_pull_error"].(string); ok {
|
||||
fmt.Fprintf(w, "⚠ Could not pull local env vars: %s\n", envPullErr)
|
||||
fmt.Fprintf(w, " run `lark-cli apps +env-pull --app-id %s` to retry\n", appID)
|
||||
}
|
||||
|
||||
@@ -20,46 +20,20 @@ import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// testRuntimeWithDir builds a *common.RuntimeContext whose backing cobra command
|
||||
// has string flags "dir" (=dirFlag) and "template" (=defaultTemplate) registered,
|
||||
// mirroring how +init reads them at runtime via rctx.Str.
|
||||
// has a string flag "dir" (=dirFlag) registered, mirroring how +init reads it
|
||||
// at runtime via rctx.Str.
|
||||
func testRuntimeWithDir(t *testing.T, dirFlag string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "init"}
|
||||
cmd.Flags().String("dir", dirFlag, "")
|
||||
cmd.Flags().String("template", defaultTemplate, "")
|
||||
return common.TestNewRuntimeContext(cmd, nil)
|
||||
}
|
||||
|
||||
// testRuntimeWithTemplate builds a *common.RuntimeContext with "dir" and
|
||||
// "template" string flags registered, mirroring +init's runtime flag set. The
|
||||
// template flag is registered with an empty default (matching the real flag,
|
||||
// which no longer carries Default: defaultTemplate); pass tpl="" to model an
|
||||
// omitted --template and a non-empty tpl to model an explicit one.
|
||||
func testRuntimeWithTemplate(t *testing.T, dirFlag, tpl string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "init"}
|
||||
cmd.Flags().String("dir", dirFlag, "")
|
||||
cmd.Flags().String("template", tpl, "")
|
||||
return common.TestNewRuntimeContext(cmd, nil)
|
||||
}
|
||||
|
||||
func TestResolveTemplate(t *testing.T) {
|
||||
if got := resolveTemplate(testRuntimeWithTemplate(t, "", "foo"), "app_x"); got != "foo" {
|
||||
t.Errorf("explicit --template = %q, want foo", got)
|
||||
}
|
||||
if got := resolveTemplate(testRuntimeWithTemplate(t, "", ""), "app_x"); got != defaultTemplate {
|
||||
t.Errorf("omitted --template = %q, want fallback %q", got, defaultTemplate)
|
||||
}
|
||||
// Whitespace-only --template is treated as omitted -> fallback.
|
||||
if got := resolveTemplate(testRuntimeWithTemplate(t, "", " "), "app_x"); got != defaultTemplate {
|
||||
t.Errorf("whitespace --template = %q, want fallback %q", got, defaultTemplate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTargetPath(t *testing.T) {
|
||||
got, err := resolveTargetPath(testRuntimeWithDir(t, ""), "app_x")
|
||||
if err != nil {
|
||||
@@ -261,12 +235,12 @@ func TestRunScaffold_EmptyRepo(t *testing.T) {
|
||||
t.Run("ls="+ls, func(t *testing.T) {
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ls}}}
|
||||
withFakeRunner(t, f)
|
||||
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "nestjs-react-fullstack")
|
||||
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "", "")
|
||||
if err != nil || kind != "init" {
|
||||
t.Fatalf("ls=%q kind=%q err=%v, want init", ls, kind, err)
|
||||
}
|
||||
c := findCall(f.calls, "npx", "-y")
|
||||
if c == nil || !containsAll(c, "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", "nestjs-react-fullstack", "--app-id", "app_x") {
|
||||
if c == nil || !containsAll(c, "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--app-type", "full_stack", "--app-id", "app_x") {
|
||||
t.Errorf("app init not invoked with expected args: %v", f.calls)
|
||||
}
|
||||
if c != nil && containsAll(c, "--local") {
|
||||
@@ -280,7 +254,7 @@ func TestRunScaffold_NonEmpty_SyncsWhenNoSteering(t *testing.T) {
|
||||
dir := t.TempDir() // no steering dir, no meta.json
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: "src/x.ts\n"}}}
|
||||
withFakeRunner(t, f)
|
||||
kind, err := runScaffold(context.Background(), dir, "app_x", "nestjs-react-fullstack")
|
||||
kind, err := runScaffold(context.Background(), dir, "app_x", "", "")
|
||||
if err != nil || kind != "upgrade" {
|
||||
t.Fatalf("kind=%q err=%v, want upgrade", kind, err)
|
||||
}
|
||||
@@ -294,12 +268,24 @@ func TestRunScaffold_NonEmpty_SyncsWhenNoSteering(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunScaffold_NonEmpty_ModernHTML_SkipsSyncEvenWithoutSteering(t *testing.T) {
|
||||
dir := t.TempDir() // no steering dir → sync would run for non-modern_html
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: "src/x.ts\n"}}}
|
||||
withFakeRunner(t, f)
|
||||
if _, err := runScaffold(context.Background(), dir, "app_x", "modern_html", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if findCallArg(f.calls, "npx", "skills", "sync") != nil {
|
||||
t.Error("skills sync must be skipped for modern_html regardless of steering dir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunScaffold_NonEmpty_SkipsSyncWhenSteeringExists(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.MkdirAll(filepath.Join(dir, steeringRelPath), 0o755)
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: "src/x.ts\n"}}}
|
||||
withFakeRunner(t, f)
|
||||
if _, err := runScaffold(context.Background(), dir, "app_x", "nestjs-react-fullstack"); err != nil {
|
||||
if _, err := runScaffold(context.Background(), dir, "app_x", "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if findCallArg(f.calls, "npx", "skills", "sync") != nil {
|
||||
@@ -313,7 +299,7 @@ func TestRunScaffold_AppInitFailure(t *testing.T) {
|
||||
"npx -y": {stderr: "boom", err: errors.New("exit 1")},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
if _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "nestjs-react-fullstack"); err == nil {
|
||||
if _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "", ""); err == nil {
|
||||
t.Error("app init failure must propagate")
|
||||
}
|
||||
}
|
||||
@@ -342,13 +328,13 @@ func TestAppsInit_EmptyRepo_EndToEnd(t *testing.T) {
|
||||
if _, ok := data["npx_skipped"]; ok {
|
||||
t.Error("npx_skipped must be removed")
|
||||
}
|
||||
// --template is omitted here, so resolveTemplate falls back to
|
||||
// defaultTemplate and `app init` must still receive --template nestjs-react-fullstack.
|
||||
// appType is empty, so scaffoldInitArgs falls back to "full_stack"
|
||||
// and `app init` must still receive --app-type full_stack.
|
||||
c := findCall(f.calls, "npx", "-y")
|
||||
if c == nil {
|
||||
t.Error("npx scaffold not invoked")
|
||||
} else if !containsAll(c, "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", defaultTemplate, "--app-id", "app_x") {
|
||||
t.Errorf("app init missing expected --template fallback args: %v", c)
|
||||
} else if !containsAll(c, "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--app-type", "full_stack", "--app-id", "app_x") {
|
||||
t.Errorf("app init missing expected --app-type fallback args: %v", c)
|
||||
} else if containsAll(c, "--local") {
|
||||
t.Errorf("app init must NOT carry --local: %v", c)
|
||||
}
|
||||
@@ -751,31 +737,12 @@ func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buf
|
||||
}
|
||||
|
||||
func TestAppsInit_Req1_Wording(t *testing.T) {
|
||||
var tmpl *common.Flag
|
||||
for i := range AppsInit.Flags {
|
||||
if AppsInit.Flags[i].Name == "template" {
|
||||
tmpl = &AppsInit.Flags[i]
|
||||
}
|
||||
}
|
||||
if tmpl == nil {
|
||||
t.Fatal("--template flag missing")
|
||||
}
|
||||
if strings.Contains(strings.ToLower(tmpl.Desc), "scaffold") {
|
||||
t.Errorf("--template Desc still mentions scaffold: %q", tmpl.Desc)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(tmpl.Desc), "code-init") {
|
||||
t.Errorf("--template Desc should use code-init wording: %q", tmpl.Desc)
|
||||
}
|
||||
|
||||
// The --dry-run output is a flat object (DryRunAPI marshals to top-level keys
|
||||
// description/scaffold/api/...), NOT wrapped in {"data":...}, so parse stdout
|
||||
// directly rather than via parseEnvelopeData.
|
||||
factory, stdout, _ := newAppsExecuteFactoryWithStderr(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--as", "user", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
|
||||
data, err := decodeDryRunDataMap(stdout.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("decode dry-run output: %v (raw=%q)", err, stdout.String())
|
||||
}
|
||||
desc, _ := data["description"].(string)
|
||||
@@ -787,9 +754,8 @@ func TestAppsInit_Req1_Wording(t *testing.T) {
|
||||
t.Error("dry-run must keep machine-contract key `scaffold`")
|
||||
} else if !strings.Contains(scaffold, "skills sync --local") {
|
||||
t.Errorf("dry-run scaffold string must show --local on skills sync: %q", scaffold)
|
||||
} else if strings.Contains(scaffold, "app init --template nestjs-react-fullstack --app-id app_x --local") ||
|
||||
strings.Contains(scaffold, "app sync --local") {
|
||||
t.Errorf("dry-run scaffold string must NOT show --local on app init / app sync: %q", scaffold)
|
||||
} else if strings.Contains(scaffold, "app sync --local") {
|
||||
t.Errorf("dry-run scaffold string must NOT show --local on app sync: %q", scaffold)
|
||||
}
|
||||
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||||
@@ -1250,7 +1216,7 @@ func TestRunScaffold_NonEmpty_SyncFailure(t *testing.T) {
|
||||
"git ls-files": {stdout: "src/x.ts\n"},
|
||||
"npx -y": {err: errors.New("sync boom")},
|
||||
}})
|
||||
if _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "tpl"); err == nil {
|
||||
if _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "", ""); err == nil {
|
||||
t.Error("npx app sync failure must surface as an error")
|
||||
}
|
||||
}
|
||||
@@ -1478,8 +1444,8 @@ func TestAppsInit_DryRun_DescribesEnvPull(t *testing.T) {
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &m); err != nil {
|
||||
m, err := decodeDryRunDataMap(stdout.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("decode dry-run: %v (raw=%q)", err, stdout.String())
|
||||
}
|
||||
ep, _ := m["env_pull"].(string)
|
||||
@@ -1630,7 +1596,7 @@ func TestRunScaffold_SubprocessFailureIsExternalTool(t *testing.T) {
|
||||
"git ls-files": {stderr: "fatal: not a git repository", err: cause},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
_, err := runScaffold(context.Background(), t.TempDir(), "app_x", "nestjs-react-fullstack")
|
||||
_, err := runScaffold(context.Background(), t.TempDir(), "app_x", "", "")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error from failing git subprocess")
|
||||
}
|
||||
@@ -1645,3 +1611,368 @@ func TestRunScaffold_SubprocessFailureIsExternalTool(t *testing.T) {
|
||||
t.Fatalf("cause chain not preserved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunScaffold_HtmlPassesTemplate(t *testing.T) {
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ""}}}
|
||||
withFakeRunner(t, f)
|
||||
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "html", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if kind != scaffoldKindInit {
|
||||
t.Errorf("kind = %q, want %q", kind, scaffoldKindInit)
|
||||
}
|
||||
c := findCall(f.calls, "npx", "-y")
|
||||
if c == nil {
|
||||
t.Fatal("npx not called")
|
||||
}
|
||||
if !containsAll(c, "--app-type", "html") {
|
||||
t.Errorf("expected --app-type html in args: %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunScaffold_ModernHtmlPassesTemplate(t *testing.T) {
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ""}}}
|
||||
withFakeRunner(t, f)
|
||||
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "modern_html", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if kind != scaffoldKindInit {
|
||||
t.Errorf("kind = %q, want %q", kind, scaffoldKindInit)
|
||||
}
|
||||
c := findCall(f.calls, "npx", "-y")
|
||||
if c == nil {
|
||||
t.Fatal("npx not called")
|
||||
}
|
||||
if !containsAll(c, "--app-type", "modern_html") {
|
||||
t.Errorf("expected --app-type modern_html in args: %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunScaffold_EmptyAppTypeFallback(t *testing.T) {
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ""}}}
|
||||
withFakeRunner(t, f)
|
||||
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if kind != scaffoldKindInit {
|
||||
t.Errorf("kind = %q, want %q", kind, scaffoldKindInit)
|
||||
}
|
||||
c := findCall(f.calls, "npx", "-y")
|
||||
if c == nil {
|
||||
t.Fatal("npx not called")
|
||||
}
|
||||
if !containsAll(c, "--app-type", "full_stack") {
|
||||
t.Errorf("expected --app-type full_stack in args: %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunScaffold_FullStackPassesTemplate(t *testing.T) {
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ""}}}
|
||||
withFakeRunner(t, f)
|
||||
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "full_stack", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if kind != scaffoldKindInit {
|
||||
t.Errorf("kind = %q, want %q", kind, scaffoldKindInit)
|
||||
}
|
||||
c := findCall(f.calls, "npx", "-y")
|
||||
if c == nil {
|
||||
t.Fatal("npx not called")
|
||||
}
|
||||
if !containsAll(c, "--app-type", "full_stack") {
|
||||
t.Errorf("expected --app-type full_stack in args: %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScaffoldInitArgs_WithAppType(t *testing.T) {
|
||||
args := scaffoldInitArgs("modern_html", "app_x", "")
|
||||
if !containsAll(args, "--app-type", "modern_html", "--app-id", "app_x") {
|
||||
t.Errorf("expected --app-type modern_html --app-id app_x, got %v", args)
|
||||
}
|
||||
// modern_html skips dependency install.
|
||||
if !containsAll(args, "--skip-install") {
|
||||
t.Errorf("expected --skip-install for modern_html, got %v", args)
|
||||
}
|
||||
for _, a := range args {
|
||||
if a == "--source-path" {
|
||||
t.Errorf("--source-path must not appear when sourcePath is empty: %v", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyForAppType(t *testing.T) {
|
||||
// modern_html decouples all control points: skip install, env-pull, skills sync.
|
||||
if p := policyForAppType("modern_html"); !p.skipInstall || !p.skipEnvPull || !p.skipSkillsSync {
|
||||
t.Errorf("modern_html policy = %+v, want all skip flags set", p)
|
||||
}
|
||||
// Unlisted types (including "") get the zero-value policy: everything runs.
|
||||
for _, at := range []string{"full_stack", "", "backend"} {
|
||||
if p := policyForAppType(at); p.skipInstall || p.skipEnvPull || p.skipSkillsSync {
|
||||
t.Errorf("policy for %q = %+v, want zero value", at, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScaffoldInitArgs_SkipInstallOnlyForModernHTML(t *testing.T) {
|
||||
// Non-modern_html types run the install step (no --skip-install).
|
||||
for _, at := range []string{"full_stack", "", "backend"} {
|
||||
args := scaffoldInitArgs(at, "app_x", "")
|
||||
for _, a := range args {
|
||||
if a == "--skip-install" {
|
||||
t.Errorf("--skip-install must not appear for app-type %q: %v", at, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScaffoldInitArgs_EmptyFallback(t *testing.T) {
|
||||
args := scaffoldInitArgs("", "app_x", "")
|
||||
if !containsAll(args, "--app-type", "full_stack", "--app-id", "app_x") {
|
||||
t.Errorf("expected --app-type full_stack fallback, got %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScaffoldInitArgs_WithSourcePath(t *testing.T) {
|
||||
args := scaffoldInitArgs("modern_html", "app_x", "/path/to/src")
|
||||
if !containsAll(args, "--app-type", "modern_html", "--app-id", "app_x", "--source-path", "/path/to/src") {
|
||||
t.Errorf("expected --source-path /path/to/src, got %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
// configSetValue finds a `git config <key> <value>` SET call (not a `--get`)
|
||||
// in the recorded fake calls and returns its value.
|
||||
func configSetValue(calls [][]string, key string) (string, bool) {
|
||||
for _, c := range calls {
|
||||
if len(c) >= 5 && c[1] == "git" && c[2] == "config" && c[3] == key {
|
||||
return c[4], true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func TestEnsureGitIdentity_SetsDefaultsWhenUnset(t *testing.T) {
|
||||
f := &fakeCommandRunner{} // no "git config" result → `--get` returns empty stdout
|
||||
withFakeRunner(t, f)
|
||||
if err := ensureGitIdentity(context.Background(), "/repo"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if v, ok := configSetValue(f.calls, "user.name"); !ok || v != defaultGitUserName {
|
||||
t.Errorf("user.name set = (%q,%v), want %q", v, ok, defaultGitUserName)
|
||||
}
|
||||
if v, ok := configSetValue(f.calls, "user.email"); !ok || v != defaultGitUserEmail {
|
||||
t.Errorf("user.email set = (%q,%v), want %q", v, ok, defaultGitUserEmail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureGitIdentity_RespectsExisting(t *testing.T) {
|
||||
// `git config --get` returns a value → identity resolvable, nothing is set.
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||||
"git config": {stdout: "Existing Dev\n"},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
if err := ensureGitIdentity(context.Background(), "/repo"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if _, ok := configSetValue(f.calls, "user.name"); ok {
|
||||
t.Error("user.name must not be overwritten when already configured")
|
||||
}
|
||||
if _, ok := configSetValue(f.calls, "user.email"); ok {
|
||||
t.Error("user.email must not be overwritten when already configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureGitIdentity_SetFailurePropagates(t *testing.T) {
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||||
"git config": {stderr: "boom", err: errors.New("exit 1")},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
if err := ensureGitIdentity(context.Background(), "/repo"); err == nil {
|
||||
t.Error("expected error when git config set fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsInit_WithAppType_FreshClone(t *testing.T) {
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||||
"credential-init": credInitOK("http://u:t@h/app_typed.git"),
|
||||
"git clone": {},
|
||||
"git checkout": {},
|
||||
"git ls-files": {stdout: ""},
|
||||
"git status": {stdout: " A src/app.ts\n"},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
|
||||
// Register a meta mock so queryAppType returns "modern_html"
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_typed",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"app_id": "app_typed",
|
||||
"app_type": "MODERN_HTML",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
dir := relCloneDir(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_typed", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := parseEnvelopeData(t, stdout)
|
||||
if data["app_type"] != "modern_html" {
|
||||
t.Errorf("app_type = %v, want modern_html", data["app_type"])
|
||||
}
|
||||
// Verify the scaffold used --app-type modern_html
|
||||
c := findCall(f.calls, "npx", "-y")
|
||||
if c == nil {
|
||||
t.Fatal("npx not called")
|
||||
}
|
||||
if !containsAll(c, "--app-type", "modern_html") {
|
||||
t.Errorf("expected --app-type modern_html, got %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsInit_ModernHtml_SkipsEnvPull(t *testing.T) {
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||||
"credential-init": credInitOK("https://git.test/app_mh.git"),
|
||||
"git clone": {},
|
||||
"git checkout": {},
|
||||
"git ls-files": {stdout: ""},
|
||||
"npx -y": {},
|
||||
"git status": {stdout: ""},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_mh",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"app_id": "app_mh",
|
||||
"app_type": "MODERN_HTML",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
dir := relCloneDir(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_mh", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := parseEnvelopeData(t, stdout)
|
||||
if data["env_pull_skipped"] != true {
|
||||
t.Errorf("env_pull_skipped = %v, want true", data["env_pull_skipped"])
|
||||
}
|
||||
if data["env_pulled"] != false {
|
||||
t.Errorf("env_pulled = %v, want false", data["env_pulled"])
|
||||
}
|
||||
// Verify env-pull was NOT called
|
||||
for _, c := range f.calls {
|
||||
if len(c) >= 3 && c[2] == "apps" && len(c) >= 4 && c[3] == "+env-pull" {
|
||||
t.Fatal("env-pull should not be called for modern_html")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsInit_AlreadyInitialized_ModernHtml_SkipsEnvPull(t *testing.T) {
|
||||
dir := relCloneDir(t)
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(abs, ".spark"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(abs, metaRelPath), []byte(`{"app_id":"app_mh2"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeCommandRunner{}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_mh2",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"app_id": "app_mh2",
|
||||
"app_type": "MODERN_HTML",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_mh2", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := parseEnvelopeData(t, stdout)
|
||||
if data["scaffold"] != "already_initialized" {
|
||||
t.Errorf("scaffold = %v, want already_initialized", data["scaffold"])
|
||||
}
|
||||
if data["env_pull_skipped"] != true {
|
||||
t.Errorf("env_pull_skipped = %v, want true", data["env_pull_skipped"])
|
||||
}
|
||||
if len(f.calls) != 0 {
|
||||
t.Errorf("no commands should be called for already-initialized modern_html, got %v", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsInit_WithAppType_AlreadyInitialized(t *testing.T) {
|
||||
dir := relCloneDir(t)
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(abs, ".spark"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(abs, metaRelPath), []byte(`{"app_id":"app_typed2"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
envFile := filepath.Join(abs, ".env.local")
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(envFile)}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
|
||||
// Register meta mock so queryAppType returns "html"
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_typed2",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"app_id": "app_typed2",
|
||||
"app_type": "HTML",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_typed2", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := parseEnvelopeData(t, stdout)
|
||||
if data["scaffold"] != "already_initialized" {
|
||||
t.Errorf("scaffold = %v, want already_initialized", data["scaffold"])
|
||||
}
|
||||
if data["app_type"] != "html" {
|
||||
t.Errorf("app_type = %v, want html", data["app_type"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,13 +25,7 @@ func TestAppsLogList_DryRunBuildsSearchLogsBody(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
|
||||
34
shortcuts/apps/apps_meta.go
Normal file
34
shortcuts/apps/apps_meta.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// queryAppType fetches the app's type string from the server via
|
||||
// GET /open-apis/spark/v1/apps/{appID}. The server returns uppercase
|
||||
// values ("HTML", "FULL_STACK", "MODERN_HTML"); this function normalizes
|
||||
// to lowercase. Returns "" when the API is unavailable or returns an
|
||||
// error — callers fall back to legacy behavior.
|
||||
func queryAppType(ctx context.Context, rctx *common.RuntimeContext, appID string) string {
|
||||
path := fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))
|
||||
data, err := rctx.CallAPITyped("GET", path, nil, nil)
|
||||
if err != nil {
|
||||
fmt.Fprintf(rctx.IO().ErrOut, "→ Could not query app type: %v\n", err)
|
||||
return ""
|
||||
}
|
||||
appRaw, _ := data["app"].(map[string]interface{})
|
||||
if appRaw == nil {
|
||||
fmt.Fprintf(rctx.IO().ErrOut, "→ Could not query app type: response missing app object\n")
|
||||
return ""
|
||||
}
|
||||
appType, _ := appRaw["app_type"].(string)
|
||||
return strings.ToLower(appType)
|
||||
}
|
||||
148
shortcuts/apps/apps_meta_test.go
Normal file
148
shortcuts/apps/apps_meta_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newMetaTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_meta_test"}
|
||||
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
rt := common.TestNewRuntimeContextForAPI(
|
||||
context.Background(),
|
||||
&cobra.Command{Use: "+meta-test"},
|
||||
cfg, f, core.AsUser,
|
||||
)
|
||||
return rt, reg
|
||||
}
|
||||
|
||||
func TestQueryAppType_Success(t *testing.T) {
|
||||
rt, reg := newMetaTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_test",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"app_id": "app_test",
|
||||
"app_type": "MODERN_HTML",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result := queryAppType(context.Background(), rt, "app_test")
|
||||
if result != "modern_html" {
|
||||
t.Errorf("queryAppType = %q, want modern_html", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryAppType_FullStack(t *testing.T) {
|
||||
rt, reg := newMetaTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_fs",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"app_id": "app_fs",
|
||||
"app_type": "FULL_STACK",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result := queryAppType(context.Background(), rt, "app_fs")
|
||||
if result != "full_stack" {
|
||||
t.Errorf("queryAppType = %q, want full_stack", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryAppType_Html(t *testing.T) {
|
||||
rt, reg := newMetaTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_html",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"app_id": "app_html",
|
||||
"app_type": "HTML",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result := queryAppType(context.Background(), rt, "app_html")
|
||||
if result != "html" {
|
||||
t.Errorf("queryAppType = %q, want html", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryAppType_APIError(t *testing.T) {
|
||||
rt, reg := newMetaTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_bad",
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{"code": float64(99999), "msg": "internal error"},
|
||||
})
|
||||
|
||||
result := queryAppType(context.Background(), rt, "app_bad")
|
||||
if result != "" {
|
||||
t.Errorf("queryAppType = %q, want empty on error", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryAppType_MissingAppObject(t *testing.T) {
|
||||
rt, reg := newMetaTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_no",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
result := queryAppType(context.Background(), rt, "app_no")
|
||||
if result != "" {
|
||||
t.Errorf("queryAppType = %q, want empty when app object missing", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryAppType_EmptyAppType(t *testing.T) {
|
||||
rt, reg := newMetaTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_empty",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"app_id": "app_empty",
|
||||
"app_type": "",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result := queryAppType(context.Background(), rt, "app_empty")
|
||||
if result != "" {
|
||||
t.Errorf("queryAppType = %q, want empty when app_type is empty", result)
|
||||
}
|
||||
}
|
||||
@@ -39,13 +39,7 @@ func TestAppsMetricList_DryRunUsesSeconds(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
@@ -90,11 +84,7 @@ func TestAppsMetricList_AutoDownSampleByRange(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
|
||||
@@ -56,9 +56,10 @@ var AppsReleaseCreate = common.Shortcut{
|
||||
out := map[string]interface{}{
|
||||
"release_id": common.GetString(data, "release_id"),
|
||||
"status": common.GetString(data, "status"),
|
||||
"sync": common.GetBool(data, "sync"),
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "release_id: %s\nstatus: %s\n", out["release_id"], out["status"])
|
||||
fmt.Fprintf(w, "release_id: %s\nstatus: %s\nsync: %v\n", out["release_id"], out["status"], out["sync"])
|
||||
})
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -105,3 +105,45 @@ func TestAppsReleaseCreateExecute_Success(t *testing.T) {
|
||||
t.Errorf("status = %v, want publishing", env.Data["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsReleaseCreate_SyncField(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newReleaseCreateRuntimeContext(t, "app_sync", "main")
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_sync/releases",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": map[string]interface{}{
|
||||
"release_id": "456",
|
||||
"status": "publishing",
|
||||
"sync": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := AppsReleaseCreate.Execute(context.Background(), rctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdoutBuf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal output: %v\nraw: %s", err, stdoutBuf.String())
|
||||
}
|
||||
if !env.OK {
|
||||
t.Fatalf("expected ok=true, got: %s", stdoutBuf.String())
|
||||
}
|
||||
if env.Data["release_id"] != "456" {
|
||||
t.Errorf("release_id = %v, want 456", env.Data["release_id"])
|
||||
}
|
||||
if env.Data["status"] != "publishing" {
|
||||
t.Errorf("status = %v, want publishing", env.Data["status"])
|
||||
}
|
||||
if env.Data["sync"] != true {
|
||||
t.Errorf("sync = %v, want true", env.Data["sync"])
|
||||
}
|
||||
}
|
||||
|
||||
744
shortcuts/apps/apps_role.go
Normal file
744
shortcuts/apps/apps_role.go
Normal file
@@ -0,0 +1,744 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const maxRoleListScanPages = 1000
|
||||
|
||||
// AppsRoleList lists app roles.
|
||||
var AppsRoleList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+role-list",
|
||||
Description: "List app roles",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +role-list --app-id <app_id>",
|
||||
"Example: lark-cli apps +role-list --app-id <app_id> --name Admin --page-size 20",
|
||||
"When only a role name is known, pass --name for exact matching; call +role-get only after resolving one unique role_id",
|
||||
"With --name, the CLI scans server pages in batches of 100, then applies --page-size and --page-token to the exact local matches",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
|
||||
{Name: "name", Desc: "filter roles by exact name"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
|
||||
{Name: "page-token", Desc: "integer offset returned by the previous role-list response"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if err := validateRoleAppID(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := buildRoleListParams(rctx)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
// Validate already ran and called buildRoleListParams; error is impossible here.
|
||||
params, _ := buildRoleListParams(rctx)
|
||||
params = roleListRequestParams(params, 0)
|
||||
return common.NewDryRunAPI().
|
||||
GET(roleListURL(rctx)).
|
||||
Desc("List app roles").
|
||||
Params(params)
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
params, err := buildRoleListParams(rctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := executeRoleList(rctx, params)
|
||||
if err != nil {
|
||||
return withRoleErrorHint(err, roleOperationList)
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderRoleListPretty(w, common.GetSlice(data, "items"))
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// AppsRoleGet gets one app role.
|
||||
var AppsRoleGet = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+role-get",
|
||||
Description: "Get an app role",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +role-get --app-id <app_id> --role-id <role_id>",
|
||||
"--role-id is not a human-readable role name; if only a name is known, run +role-list --name <exact_name> and use its unique returned role_id before calling +role-get",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
|
||||
{Name: "role-id", Desc: roleIDRequiredDesc, Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
return validateRoleID(rctx)
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
GET(roleItemURL(rctx)).
|
||||
Desc("Get app role")
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
data, err := rctx.CallAPITyped("GET", roleItemURL(rctx), nil, nil)
|
||||
if err != nil {
|
||||
return withRoleErrorHint(err, roleOperationGet)
|
||||
}
|
||||
role, err := parseRoleDetailResponseData(data, roleID(rctx))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderRoleGetPretty(w, role)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// AppsRoleCreate creates an app role.
|
||||
var AppsRoleCreate = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+role-create",
|
||||
Description: "Create an app role",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +role-create --app-id <app_id> --name Admin",
|
||||
"Example: lark-cli apps +role-create --app-id <app_id> --name Admin --description 'Can manage orders'",
|
||||
"Example: lark-cli apps +role-create --app-id <app_id> --name Admin --role-id role_admin",
|
||||
"The create response returns data.role; run +role-get with data.role.role_id only when independent verification is required",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
|
||||
// Keep --name in Validate so the CLI can return the command-specific
|
||||
// non-invention hint instead of Cobra's generic required-flag error.
|
||||
{Name: "name", Desc: "role name (required)"},
|
||||
{Name: "description", Desc: "role description"},
|
||||
{Name: "role-id", Desc: "optional caller-provided role ID ([A-Za-z0-9_-]{1,64})"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if err := validateRoleAppID(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("name")) == "" {
|
||||
return appsValidationParamError("--name", "--name is required").
|
||||
WithHint("ask for the intended role name and pass it with --name; do not infer a name from --description")
|
||||
}
|
||||
if rctx.Changed("role-id") {
|
||||
roleID := strings.TrimSpace(rctx.Str("role-id"))
|
||||
if roleID == "" {
|
||||
return appsValidationParamError("--role-id", "--role-id must not be empty when provided")
|
||||
}
|
||||
return validateOptionalRoleID(roleID)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST(roleListURL(rctx)).
|
||||
Desc("Create app role").
|
||||
Body(buildRoleCreateBody(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
data, err := rctx.CallAPITyped("POST", roleListURL(rctx), nil, buildRoleCreateBody(rctx))
|
||||
if err != nil {
|
||||
return withRoleErrorHint(err, roleOperationCreate)
|
||||
}
|
||||
expectedRoleID := ""
|
||||
if rctx.Changed("role-id") {
|
||||
expectedRoleID = roleID(rctx)
|
||||
}
|
||||
role, err := parseRoleWriteResponseData(data, expectedRoleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderRoleCreatePretty(w, role)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// AppsRoleUpdate updates an app role.
|
||||
var AppsRoleUpdate = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+role-update",
|
||||
Description: "Update an app role",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +role-update --app-id <app_id> --role-id <role_id> --name Operator",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
|
||||
{Name: "role-id", Desc: roleIDRequiredDesc, Required: true},
|
||||
{Name: "name", Desc: "new role name"},
|
||||
{Name: "description", Desc: "new role description"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if err := validateRoleID(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if rctx.Changed("name") && strings.TrimSpace(rctx.Str("name")) == "" {
|
||||
return appsValidationParamError("--name", "--name must not be empty when provided").
|
||||
WithHint("omit --name if only updating --description")
|
||||
}
|
||||
if !rctx.Changed("name") && !rctx.Changed("description") {
|
||||
reason := "provide at least one of --name or --description"
|
||||
return appsValidationError("at least one of --name or --description is required").
|
||||
WithParams(
|
||||
appsInvalidParam("--name", reason),
|
||||
appsInvalidParam("--description", reason),
|
||||
).
|
||||
WithHint("provide --name, --description, or both")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
PATCH(roleItemURL(rctx)).
|
||||
Desc("Update app role").
|
||||
Body(buildRoleUpdateBody(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
data, err := rctx.CallAPITyped("PATCH", roleItemURL(rctx), nil, buildRoleUpdateBody(rctx))
|
||||
if err != nil {
|
||||
return withRoleErrorHint(err, roleOperationUpdate)
|
||||
}
|
||||
role, err := parseRoleWriteResponseData(data, roleID(rctx))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderRoleUpdatePretty(w, role)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// AppsRoleDelete deletes an app role.
|
||||
var AppsRoleDelete = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+role-delete",
|
||||
Description: "Delete an app role",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +role-delete --app-id <app_id> --role-id <role_id> --yes",
|
||||
"A delete request alone is not explicit confirmation: first show the exact app, role, current member scope, and irreversible impact; use --yes only after the user confirms that impact",
|
||||
"When independent verification is required, use +role-list --name <exact_name> and confirm the deleted role_id is absent; a failed +role-get alone does not prove deletion",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
|
||||
{Name: "role-id", Desc: roleIDRequiredDesc, Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
return validateRoleID(rctx)
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
DELETE(roleItemURL(rctx)).
|
||||
Desc("Delete app role")
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
data, err := rctx.CallAPITyped("DELETE", roleItemURL(rctx), nil, nil)
|
||||
if err != nil {
|
||||
return withRoleErrorHint(err, roleOperationDelete)
|
||||
}
|
||||
deletedRoleID := roleID(rctx)
|
||||
out, err := normalizeRoleDeleteData(data, deletedRoleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderRoleDeletePretty(w, common.GetString(out, "role_id"))
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func roleListURL(rctx *common.RuntimeContext) string {
|
||||
appID := roleAppID(rctx)
|
||||
return fmt.Sprintf(roleListPath, validate.EncodePathSegment(appID))
|
||||
}
|
||||
|
||||
func roleItemURL(rctx *common.RuntimeContext) string {
|
||||
appID := roleAppID(rctx)
|
||||
roleID := roleID(rctx)
|
||||
return fmt.Sprintf(roleItemPath, validate.EncodePathSegment(appID), validate.EncodePathSegment(roleID))
|
||||
}
|
||||
|
||||
func buildRoleListParams(rctx *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
params, err := buildRolePageParams(rctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := strings.TrimSpace(rctx.Str("name"))
|
||||
if rctx.Changed("name") && name == "" {
|
||||
return nil, appsValidationParamError("--name", "--name must not be empty when provided").
|
||||
WithHint("omit --name to list all roles, or provide the exact role name to resolve")
|
||||
}
|
||||
if name != "" {
|
||||
params["name"] = name
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
// roleListRequestParams returns the query parameters for one actual backend
|
||||
// request. Exact-name lookup always starts from server offset zero and scans in
|
||||
// maximum-sized batches; the caller's limit/offset are applied to local matches.
|
||||
func roleListRequestParams(params map[string]interface{}, page int) map[string]interface{} {
|
||||
name, _ := params["name"].(string)
|
||||
if name == "" {
|
||||
return params
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"limit": maxRolePageSize,
|
||||
"offset": page * maxRolePageSize,
|
||||
"name": name,
|
||||
}
|
||||
}
|
||||
|
||||
func buildRoleCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"name": strings.TrimSpace(rctx.Str("name")),
|
||||
}
|
||||
if rctx.Changed("description") {
|
||||
body["description"] = strings.TrimSpace(rctx.Str("description"))
|
||||
}
|
||||
if rctx.Changed("role-id") {
|
||||
if roleID := strings.TrimSpace(rctx.Str("role-id")); roleID != "" {
|
||||
body["role_id"] = roleID
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func buildRoleUpdateBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if rctx.Changed("name") {
|
||||
body["name"] = strings.TrimSpace(rctx.Str("name"))
|
||||
}
|
||||
if rctx.Changed("description") {
|
||||
body["description"] = strings.TrimSpace(rctx.Str("description"))
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// executeRoleList compensates for Miaoda environments that accept the name
|
||||
// query parameter but ignore it. A name lookup scans the complete server-side
|
||||
// result set, applies exact matching locally, and then applies the CLI's
|
||||
// offset/limit contract to the filtered result.
|
||||
func executeRoleList(rctx *common.RuntimeContext, params map[string]interface{}) (map[string]interface{}, error) {
|
||||
name, _ := params["name"].(string)
|
||||
if name == "" {
|
||||
data, err := rctx.CallAPITyped("GET", roleListURL(rctx), params, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return normalizeRoleListData(data, params)
|
||||
}
|
||||
|
||||
requestedLimit := roleIntValue(params["limit"])
|
||||
requestedOffset := roleIntValue(params["offset"])
|
||||
allMatches := make([]interface{}, 0, requestedLimit)
|
||||
var firstPage map[string]interface{}
|
||||
seenRoleIDs := map[string]struct{}{}
|
||||
seenPageSignatures := map[string]struct{}{}
|
||||
expectedTotal := -1
|
||||
scannedRoleCount := 0
|
||||
|
||||
for page := 0; ; page++ {
|
||||
if page >= maxRoleListScanPages {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"role list exceeded %d pages while filtering by name",
|
||||
maxRoleListScanPages,
|
||||
).WithHint("retry without --name and paginate using the returned page_token")
|
||||
}
|
||||
|
||||
scanParams := roleListRequestParams(params, page)
|
||||
data, err := rctx.CallAPITyped("GET", roleListURL(rctx), scanParams, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if firstPage == nil {
|
||||
firstPage = data
|
||||
}
|
||||
items, hasMore, total, err := parseRoleListPage(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expectedTotal < 0 {
|
||||
expectedTotal = total
|
||||
} else if total != expectedTotal {
|
||||
return nil, roleListProgressError("role list total changed across pages while filtering by name")
|
||||
}
|
||||
if scannedRoleCount+len(items) > expectedTotal {
|
||||
return nil, roleListProgressError("role list returned more roles than its total while filtering by name")
|
||||
}
|
||||
scannedRoleCount += len(items)
|
||||
if hasMore && scannedRoleCount >= expectedTotal {
|
||||
return nil, roleListProgressError("role list reported more pages after reaching its total while filtering by name")
|
||||
}
|
||||
if !hasMore && scannedRoleCount != expectedTotal {
|
||||
return nil, roleListProgressError("role list ended before returning its declared total while filtering by name")
|
||||
}
|
||||
signature, newRoleCount, err := roleListPageProgress(items, seenRoleIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if newRoleCount != len(items) {
|
||||
return nil, roleListProgressError("role list repeated roles across pages while filtering by name")
|
||||
}
|
||||
if _, duplicate := seenPageSignatures[signature]; duplicate {
|
||||
return nil, roleListProgressError("role list repeated a page while filtering by name")
|
||||
}
|
||||
seenPageSignatures[signature] = struct{}{}
|
||||
if hasMore && (len(items) == 0 || newRoleCount == 0) {
|
||||
return nil, roleListProgressError("role list reported more pages without returning new roles")
|
||||
}
|
||||
for _, item := range items {
|
||||
role, ok := item.(map[string]interface{})
|
||||
if ok && common.GetString(role, "name") == name {
|
||||
allMatches = append(allMatches, item)
|
||||
}
|
||||
}
|
||||
if !hasMore {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if firstPage == nil {
|
||||
firstPage = map[string]interface{}{}
|
||||
}
|
||||
return normalizeFilteredRoleListData(firstPage, allMatches, requestedOffset, requestedLimit), nil
|
||||
}
|
||||
|
||||
func normalizeFilteredRoleListData(data map[string]interface{}, matches []interface{}, offset, limit int) map[string]interface{} {
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range data {
|
||||
out[k] = v
|
||||
}
|
||||
|
||||
start := offset
|
||||
if start > len(matches) {
|
||||
start = len(matches)
|
||||
}
|
||||
end := start + limit
|
||||
if end > len(matches) {
|
||||
end = len(matches)
|
||||
}
|
||||
hasMore := end < len(matches)
|
||||
items := append([]interface{}(nil), matches[start:end]...)
|
||||
if items == nil {
|
||||
items = []interface{}{}
|
||||
}
|
||||
out["items"] = items
|
||||
out["has_more"] = hasMore
|
||||
out["page_token"] = roleNextPageToken(start, limit, hasMore)
|
||||
out["total"] = len(matches)
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeRoleListData(data map[string]interface{}, params map[string]interface{}) (map[string]interface{}, error) {
|
||||
items, hasMore, total, err := parseRoleListPage(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range data {
|
||||
out[k] = v
|
||||
}
|
||||
|
||||
limit := roleIntValue(params["limit"])
|
||||
offset := roleIntValue(params["offset"])
|
||||
|
||||
out["items"] = items
|
||||
out["has_more"] = hasMore
|
||||
out["page_token"] = roleNextPageToken(offset, limit, hasMore)
|
||||
out["total"] = total
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseRoleListPage(data map[string]interface{}) ([]interface{}, bool, int, error) {
|
||||
rawItems, hasItems := data["items"]
|
||||
items, ok := rawItems.([]interface{})
|
||||
if !hasItems || !ok {
|
||||
return nil, false, 0, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"role list response field items must be an array",
|
||||
).WithHint("retry the read; do not treat a missing or malformed role list as empty")
|
||||
}
|
||||
if err := validateRoleCollection(items, "role list response field items"); err != nil {
|
||||
return nil, false, 0, err
|
||||
}
|
||||
rawHasMore, hasHasMore := data["has_more"]
|
||||
hasMore, ok := rawHasMore.(bool)
|
||||
if !hasHasMore || !ok {
|
||||
return nil, false, 0, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"role list response field has_more must be a boolean",
|
||||
).WithHint("retry the read; pagination is incomplete without a valid has_more value")
|
||||
}
|
||||
total, ok := nonNegativeRoleInteger(data["total"])
|
||||
if _, exists := data["total"]; !exists || !ok {
|
||||
return nil, false, 0, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"role list response field total must be a non-negative integer",
|
||||
).WithHint("retry the read; do not infer a role count from a missing or malformed total value")
|
||||
}
|
||||
return items, hasMore, total, nil
|
||||
}
|
||||
|
||||
func roleListPageProgress(items []interface{}, seenRoleIDs map[string]struct{}) (string, int, error) {
|
||||
roleIDs := make([]string, 0, len(items))
|
||||
newRoleCount := 0
|
||||
for index, item := range items {
|
||||
_, roleID, err := roleCollectionItem(item, "role list response field items", index)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
roleIDs = append(roleIDs, roleID)
|
||||
if _, seen := seenRoleIDs[roleID]; !seen {
|
||||
seenRoleIDs[roleID] = struct{}{}
|
||||
newRoleCount++
|
||||
}
|
||||
}
|
||||
return strings.Join(roleIDs, "\x00"), newRoleCount, nil
|
||||
}
|
||||
|
||||
func nonNegativeRoleInteger(value interface{}) (int, bool) {
|
||||
maxInt := uint64(^uint(0) >> 1)
|
||||
toInt := func(value int64) (int, bool) {
|
||||
if value < 0 || uint64(value) > maxInt {
|
||||
return 0, false
|
||||
}
|
||||
return int(value), true
|
||||
}
|
||||
|
||||
switch value := value.(type) {
|
||||
case int:
|
||||
if value < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return value, true
|
||||
case int64:
|
||||
return toInt(value)
|
||||
case float64:
|
||||
maxIntExclusive := math.Ldexp(1, strconv.IntSize-1)
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 || math.Trunc(value) != value || value >= maxIntExclusive {
|
||||
return 0, false
|
||||
}
|
||||
return int(value), true
|
||||
case json.Number:
|
||||
parsed, err := value.Int64()
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return toInt(parsed)
|
||||
case string:
|
||||
if value == "" || strings.IndexFunc(value, func(r rune) bool {
|
||||
return r < '0' || r > '9'
|
||||
}) >= 0 {
|
||||
return 0, false
|
||||
}
|
||||
parsed, err := strconv.ParseUint(value, 10, strconv.IntSize)
|
||||
if err != nil || parsed > maxInt {
|
||||
return 0, false
|
||||
}
|
||||
return int(parsed), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func roleListProgressError(message string) error {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, message).
|
||||
WithHint("retry without --name and paginate manually; do not continue an incomplete exact-name scan")
|
||||
}
|
||||
|
||||
func normalizeRoleDeleteData(data map[string]interface{}, requestedRoleID string) (map[string]interface{}, error) {
|
||||
if data == nil {
|
||||
return nil, invalidRoleDeleteResponse("role delete response data must be an object")
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return map[string]interface{}{
|
||||
"role_id": requestedRoleID,
|
||||
"deleted": true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range data {
|
||||
out[k] = v
|
||||
}
|
||||
rawRoleID, ok := out["role_id"]
|
||||
if !ok {
|
||||
return nil, invalidRoleDeleteResponse("role delete response is missing role_id")
|
||||
}
|
||||
actualRoleID, stringOK := rawRoleID.(string)
|
||||
if !stringOK || actualRoleID != requestedRoleID {
|
||||
return nil, invalidRoleDeleteResponse(
|
||||
"role delete response role_id does not match requested role_id %q",
|
||||
requestedRoleID,
|
||||
)
|
||||
}
|
||||
rawDeleted, ok := out["deleted"]
|
||||
if !ok {
|
||||
return nil, invalidRoleDeleteResponse("role delete response is missing deleted")
|
||||
}
|
||||
deleted, boolOK := rawDeleted.(bool)
|
||||
if !boolOK || !deleted {
|
||||
return nil, invalidRoleDeleteResponse("role delete response did not acknowledge deletion")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type roleResponseData struct {
|
||||
RoleID string
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
func parseRoleDetailResponseData(data map[string]interface{}, expectedRoleID string) (roleResponseData, error) {
|
||||
return parseRoleResponseData(data, expectedRoleID, true)
|
||||
}
|
||||
|
||||
func parseRoleWriteResponseData(data map[string]interface{}, expectedRoleID string) (roleResponseData, error) {
|
||||
return parseRoleResponseData(data, expectedRoleID, false)
|
||||
}
|
||||
|
||||
func parseRoleResponseData(data map[string]interface{}, expectedRoleID string, requireName bool) (roleResponseData, error) {
|
||||
if data == nil {
|
||||
return roleResponseData{}, invalidRoleResponse("role response data must be an object")
|
||||
}
|
||||
rawRole, exists := data["role"]
|
||||
role, ok := rawRole.(map[string]interface{})
|
||||
if !exists || !ok || role == nil {
|
||||
return roleResponseData{}, invalidRoleResponse("role response field role must be an object")
|
||||
}
|
||||
rawRoleID, exists := role["role_id"]
|
||||
roleID, ok := rawRoleID.(string)
|
||||
roleID = strings.TrimSpace(roleID)
|
||||
if !exists || !ok || roleID == "" {
|
||||
return roleResponseData{}, invalidRoleResponse("role response field role.role_id must be a non-empty string")
|
||||
}
|
||||
if expectedRoleID != "" && roleID != expectedRoleID {
|
||||
return roleResponseData{}, invalidRoleResponse(
|
||||
"role response role_id %q does not match requested role_id %q",
|
||||
roleID,
|
||||
expectedRoleID,
|
||||
)
|
||||
}
|
||||
rawName, nameExists := role["name"]
|
||||
name, nameOK := rawName.(string)
|
||||
name = strings.TrimSpace(name)
|
||||
if requireName && !nameExists {
|
||||
return roleResponseData{}, invalidRoleResponse("role response field role.name must be a non-empty string")
|
||||
}
|
||||
if nameExists && (!nameOK || name == "") {
|
||||
return roleResponseData{}, invalidRoleResponse("role response field role.name must be a non-empty string")
|
||||
}
|
||||
rawDescription, descriptionExists := role["description"]
|
||||
description, descriptionOK := rawDescription.(string)
|
||||
if descriptionExists && !descriptionOK {
|
||||
return roleResponseData{}, invalidRoleResponse("role response field role.description must be a string")
|
||||
}
|
||||
return roleResponseData{RoleID: roleID, Name: name, Description: description}, nil
|
||||
}
|
||||
|
||||
func invalidRoleResponse(message string, args ...interface{}) error {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, message, args...).
|
||||
WithHint("retry the role read; do not treat a missing or malformed role as a successful result")
|
||||
}
|
||||
|
||||
func invalidRoleDeleteResponse(message string, args ...interface{}) error {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, message, args...).
|
||||
WithHint("do not claim deletion; verify the target role with +role-list --name <exact_name>")
|
||||
}
|
||||
|
||||
func roleIntValue(value interface{}) int {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return v
|
||||
case int64:
|
||||
return int(v)
|
||||
case float64:
|
||||
return int(v)
|
||||
case json.Number:
|
||||
i, err := strconv.Atoi(v.String())
|
||||
if err == nil {
|
||||
return i
|
||||
}
|
||||
case string:
|
||||
i, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func renderRoleCreatePretty(w io.Writer, role roleResponseData) {
|
||||
fmt.Fprintf(w, "Created role %s\n", roleDisplayValue(role.RoleID))
|
||||
}
|
||||
|
||||
func renderRoleGetPretty(w io.Writer, role roleResponseData) {
|
||||
renderRoleDetailPretty(w, role)
|
||||
}
|
||||
|
||||
func renderRoleUpdatePretty(w io.Writer, role roleResponseData) {
|
||||
fmt.Fprintf(w, "Updated role %s\n", roleDisplayValue(role.RoleID))
|
||||
}
|
||||
|
||||
func renderRoleDeletePretty(w io.Writer, roleID string) {
|
||||
fmt.Fprintf(w, "Deleted role %s\n", roleDisplayValue(roleID))
|
||||
}
|
||||
|
||||
func renderRoleDetailPretty(w io.Writer, role roleResponseData) {
|
||||
fmt.Fprintf(w, "role_id: %s\n", roleDisplayValue(role.RoleID))
|
||||
fmt.Fprintf(w, "name: %s\n", roleDisplayValue(role.Name))
|
||||
fmt.Fprintf(w, "description: %s\n", roleDisplayValue(role.Description))
|
||||
}
|
||||
|
||||
func renderRoleListPretty(w io.Writer, items []interface{}) {
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "ROLE ID\tNAME\tDESCRIPTION")
|
||||
for _, item := range items {
|
||||
role, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\n",
|
||||
roleDisplayValue(firstNonEmpty(common.GetString(role, "role_id"), common.GetString(role, "id"))),
|
||||
roleDisplayValue(common.GetString(role, "name")),
|
||||
roleDisplayValue(common.GetString(role, "description")))
|
||||
}
|
||||
_ = tw.Flush()
|
||||
}
|
||||
490
shortcuts/apps/apps_role_common.go
Normal file
490
shortcuts/apps/apps_role_common.go
Normal file
@@ -0,0 +1,490 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
roleListPath = apiBasePath + "/apps/%s/roles"
|
||||
roleItemPath = apiBasePath + "/apps/%s/roles/%s"
|
||||
roleMemberListPath = apiBasePath + "/apps/%s/roles/%s/member_list"
|
||||
roleMemberAddPath = apiBasePath + "/apps/%s/roles/%s/member_add"
|
||||
roleMemberRemovePath = apiBasePath + "/apps/%s/roles/%s/member_remove"
|
||||
roleMatchListPath = apiBasePath + "/apps/%s/user_role_list"
|
||||
defaultRolePageSize = 20
|
||||
maxRolePageSize = 100
|
||||
maxRoleMembers = 100
|
||||
|
||||
roleErrInvalidParameters = 3340001
|
||||
roleErrUserLimitExceeded = 3344027
|
||||
roleErrDepartmentLimitExceeded = 3344028
|
||||
roleErrChatLimitExceeded = 3344029
|
||||
roleErrAdminRequired = 3344030
|
||||
roleErrManagerRequired = 3344031
|
||||
roleErrInvalidRoleID = 3344034
|
||||
roleErrRoleNotFound = 3344035
|
||||
roleErrRoleAlreadyExists = 3344036
|
||||
roleErrRoleLimitExceeded = 3344037
|
||||
roleErrInvalidRoleName = 3344038
|
||||
roleErrInvalidRoleDescription = 3344039
|
||||
roleErrUnsupportedMemberType = 3344040
|
||||
roleErrInvalidMemberID = 3344041
|
||||
)
|
||||
|
||||
var optionalRoleIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||||
|
||||
const (
|
||||
roleAppHint = "verify --app-id is a Miaoda app_id you can access; list apps with `lark-cli apps +list`"
|
||||
roleItemHint = "verify --role-id belongs to the app; if you only know a role name, resolve it with `lark-cli apps +role-list --app-id <app_id> --name <exact_name>` and use the unique returned role_id"
|
||||
roleCreateHint = "verify --app-id and role fields; omit --role-id unless you need a caller-provided role ID"
|
||||
roleMemberHint = "verify --role-id and member IDs; use user open_id, open_department_id, or open_chat_id values"
|
||||
roleMatchHint = "use --user-id with a user open_id; do not pass role_id or enumerate roles manually"
|
||||
|
||||
roleAppIDRequiredDesc = "Miaoda app ID (required; app_...; use apps +list to find it)"
|
||||
roleIDRequiredDesc = "role ID (required; [A-Za-z0-9_-]{1,64}; use role-list to find it)"
|
||||
roleUserIDRequiredDesc = "user open ID (required; ou_...; do not pass a role ID, name, or email)"
|
||||
)
|
||||
|
||||
type roleErrorOperation uint8
|
||||
|
||||
const (
|
||||
roleOperationList roleErrorOperation = iota
|
||||
roleOperationGet
|
||||
roleOperationCreate
|
||||
roleOperationUpdate
|
||||
roleOperationDelete
|
||||
roleOperationMemberList
|
||||
roleOperationMemberAdd
|
||||
roleOperationMemberRemove
|
||||
roleOperationMatchList
|
||||
)
|
||||
|
||||
type roleMemberGroups struct {
|
||||
Users []string `json:"users"`
|
||||
Departments []string `json:"departments"`
|
||||
Chats []string `json:"chats"`
|
||||
}
|
||||
|
||||
type roleMemberKind struct {
|
||||
memberType string
|
||||
dataKey string
|
||||
flagName string
|
||||
prefix string
|
||||
}
|
||||
|
||||
var roleMemberKinds = []roleMemberKind{
|
||||
{memberType: "user", dataKey: "users", flagName: "--users", prefix: "ou_"},
|
||||
{memberType: "department", dataKey: "departments", flagName: "--departments", prefix: "od-"},
|
||||
{memberType: "chat", dataKey: "chats", flagName: "--chats", prefix: "oc_"},
|
||||
}
|
||||
|
||||
func roleAppID(rctx *common.RuntimeContext) string {
|
||||
return strings.TrimSpace(rctx.Str("app-id"))
|
||||
}
|
||||
|
||||
func roleID(rctx *common.RuntimeContext) string {
|
||||
return strings.TrimSpace(rctx.Str("role-id"))
|
||||
}
|
||||
|
||||
func validateRoleAppID(rctx *common.RuntimeContext) error {
|
||||
appID := roleAppID(rctx)
|
||||
if appID == "" {
|
||||
return appsValidationParamError("--app-id", "--app-id is required").
|
||||
WithHint("list your apps with `lark-cli apps +list`")
|
||||
}
|
||||
if strings.HasPrefix(appID, "cli_") {
|
||||
return appsValidationParamError("--app-id", "--app-id must be a Miaoda app_id, not a Lark app_id").
|
||||
WithHint("pass the app_... value from `lark-cli apps +list`, not the cli_... credential app id")
|
||||
}
|
||||
if !strings.HasPrefix(appID, "app_") || len(appID) == len("app_") {
|
||||
return appsValidationParamError("--app-id", "--app-id must be a Miaoda app_id starting with app_").
|
||||
WithHint("list Miaoda apps with `lark-cli apps +list`, then pass the returned app_id")
|
||||
}
|
||||
// app-id must not contain forward slashes (apps are identified by app_xxx IDs).
|
||||
for _, r := range appID {
|
||||
if r == '/' || r == '\\' || unicode.IsSpace(r) || unicode.IsControl(r) {
|
||||
return appsValidationParamError("--app-id", "--app-id must not contain slashes, whitespace, or control characters")
|
||||
}
|
||||
}
|
||||
// Defense-in-depth: block path traversal and URL metacharacters.
|
||||
if err := validateRolePathSegmentSafe(appID, "--app-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRoleID(rctx *common.RuntimeContext) error {
|
||||
if err := validateRoleAppID(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
roleID := roleID(rctx)
|
||||
if roleID == "" {
|
||||
return appsValidationParamError("--role-id", "--role-id is required").
|
||||
WithHint("list roles with `lark-cli apps +role-list --app-id <app_id>`")
|
||||
}
|
||||
return validateExistingRoleIDValue(roleID)
|
||||
}
|
||||
|
||||
// validateRolePathSegmentSafe rejects path-traversal segments ("..") and URL
|
||||
// metacharacters (? # %) in values interpolated into a URL path, providing
|
||||
// defense-in-depth alongside validate.EncodePathSegment.
|
||||
func validateRolePathSegmentSafe(value, flagName string) error {
|
||||
for _, seg := range strings.Split(value, "/") {
|
||||
if seg == ".." {
|
||||
return appsValidationParamError(flagName, "%s must not contain '..' path traversal", flagName).
|
||||
WithHint("provide a valid %s without path traversal", flagName)
|
||||
}
|
||||
}
|
||||
if strings.ContainsAny(value, "?#%") {
|
||||
return appsValidationParamError(flagName, "%s contains invalid URL characters (?, #, %%)", flagName).
|
||||
WithHint("provide a valid %s without URL metacharacters", flagName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOptionalRoleID(roleID string) error {
|
||||
roleID = strings.TrimSpace(roleID)
|
||||
if roleID == "" {
|
||||
return nil
|
||||
}
|
||||
return validateCreateRoleIDValue(roleID)
|
||||
}
|
||||
|
||||
func validateCreateRoleIDValue(roleID string) error {
|
||||
if !optionalRoleIDPattern.MatchString(roleID) {
|
||||
return appsValidationParamError("--role-id", "--role-id must match [A-Za-z0-9_-]{1,64}").
|
||||
WithHint("omit --role-id to let the server generate one")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateExistingRoleIDValue(roleID string) error {
|
||||
if !optionalRoleIDPattern.MatchString(roleID) {
|
||||
return appsValidationParamError("--role-id", "--role-id must match [A-Za-z0-9_-]{1,64}").
|
||||
WithHint("resolve the role with `lark-cli apps +role-list --app-id <app_id> --name <exact_name>` and pass its role_id")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildRolePageParams(rctx *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
limit := defaultRolePageSize
|
||||
if rctx.Changed("page-size") {
|
||||
limit = rctx.Int("page-size")
|
||||
}
|
||||
if limit < 1 || limit > maxRolePageSize {
|
||||
return nil, appsValidationParamError("--page-size", "--page-size must be between 1 and %d", maxRolePageSize).
|
||||
WithHint("use --page-size between 1 and 100")
|
||||
}
|
||||
|
||||
offset := 0
|
||||
pageToken := strings.TrimSpace(rctx.Str("page-token"))
|
||||
if pageToken != "" {
|
||||
parsedOffset, err := strconv.Atoi(pageToken)
|
||||
if err != nil || parsedOffset < 0 {
|
||||
return nil, appsValidationParamError("--page-token", "--page-token must be a non-negative integer offset").
|
||||
WithHint("reuse page_token from the previous +role-list response")
|
||||
}
|
||||
offset = parsedOffset
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func roleNextPageToken(offset, limit int, hasMore bool) string {
|
||||
if !hasMore {
|
||||
return ""
|
||||
}
|
||||
return strconv.Itoa(offset + limit)
|
||||
}
|
||||
|
||||
func splitRoleMemberCSV(s, flagName string) ([]string, error) {
|
||||
parts := strings.Split(s, ",")
|
||||
values := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
value := strings.TrimSpace(part)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
// Reject values containing whitespace, control characters, or URL metacharacters
|
||||
// (member IDs are open_id/open_department_id/open_chat_id which are safe tokens).
|
||||
if err := validateMemberID(value, flagName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// validateMemberID rejects values containing characters that are invalid in
|
||||
// open_id / open_department_id / open_chat_id tokens (whitespace, controls, URL metacharacters).
|
||||
func validateMemberID(value, flagName string) error {
|
||||
if err := validateMemberIDPrefix(value, flagName); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range value {
|
||||
if unicode.IsSpace(r) || unicode.IsControl(r) {
|
||||
return appsValidationParamError(flagName, "member IDs must not contain whitespace or control characters").
|
||||
WithHint("pass comma-separated open_id/open_department_id/open_chat_id values without spaces")
|
||||
}
|
||||
if r == '?' || r == '#' || r == '%' || r == '/' || r == '\\' {
|
||||
return appsValidationParamError(flagName, "member IDs must not contain URL metacharacters (?, #, %, /, \\)").
|
||||
WithHint("pass comma-separated open_id/open_department_id/open_chat_id values without URL characters")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMemberIDPrefix(value, flagName string) error {
|
||||
kind, ok := roleMemberKindForFlag(flagName)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if !strings.HasPrefix(value, kind.prefix) || len(value) == len(kind.prefix) {
|
||||
return appsValidationParamError(flagName, "%s must use %s IDs", flagName, kind.prefix).
|
||||
WithHint("resolve names or emails to open IDs before calling role member commands")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func roleMemberKindForFlag(flagName string) (roleMemberKind, bool) {
|
||||
if flagName == "--user-id" {
|
||||
flagName = "--users"
|
||||
}
|
||||
for _, kind := range roleMemberKinds {
|
||||
if kind.flagName == flagName {
|
||||
return kind, true
|
||||
}
|
||||
}
|
||||
return roleMemberKind{}, false
|
||||
}
|
||||
|
||||
func roleMemberKindForType(memberType string) (roleMemberKind, bool) {
|
||||
for _, kind := range roleMemberKinds {
|
||||
if kind.memberType == memberType {
|
||||
return kind, true
|
||||
}
|
||||
}
|
||||
return roleMemberKind{}, false
|
||||
}
|
||||
|
||||
func roleDisplayValue(value string) string {
|
||||
value = validate.SanitizeForTerminal(value)
|
||||
value = strings.NewReplacer("\n", " ", "\t", " ").Replace(value)
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
// withRoleErrorHint refines documented Spark role errors with command-specific
|
||||
// recovery while preserving the typed error, numeric code, log_id, and any
|
||||
// server-provided detail. Unknown codes retain the existing Apps fallback.
|
||||
func withRoleErrorHint(err error, operation roleErrorOperation) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
hint := roleErrorHint(problem.Code, operation)
|
||||
if hint == "" {
|
||||
return withAppsHint(err, roleFallbackHint(operation))
|
||||
}
|
||||
|
||||
existing := strings.TrimSpace(problem.Hint)
|
||||
canonicalAPIHint := strings.TrimSpace(errclass.APIHint(problem.Subtype))
|
||||
switch {
|
||||
case existing == "", existing == canonicalAPIHint:
|
||||
problem.Hint = hint
|
||||
case !strings.Contains(existing, hint):
|
||||
problem.Hint = existing + "; " + hint
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func roleFallbackHint(operation roleErrorOperation) string {
|
||||
switch operation {
|
||||
case roleOperationList:
|
||||
return roleAppHint
|
||||
case roleOperationCreate:
|
||||
return roleCreateHint
|
||||
case roleOperationMemberList, roleOperationMemberAdd, roleOperationMemberRemove:
|
||||
return roleMemberHint
|
||||
case roleOperationMatchList:
|
||||
return roleMatchHint
|
||||
default:
|
||||
return roleItemHint
|
||||
}
|
||||
}
|
||||
|
||||
func roleErrorHint(code int, operation roleErrorOperation) string {
|
||||
switch code {
|
||||
case roleErrInvalidParameters:
|
||||
return roleFallbackHint(operation)
|
||||
case roleErrAdminRequired:
|
||||
return "ask an app administrator to perform this operation or grant the calling user app-administrator access"
|
||||
case roleErrManagerRequired:
|
||||
return "ask an app administrator or app developer to perform this operation, or grant the calling user app-management access"
|
||||
case roleErrInvalidRoleID:
|
||||
if operation == roleOperationCreate {
|
||||
return "omit --role-id to let the server generate one, or provide a role ID accepted by the role service"
|
||||
}
|
||||
case roleErrRoleNotFound:
|
||||
if operation == roleOperationMatchList {
|
||||
return "list the app's current roles and retry; role data used for this match may no longer be valid"
|
||||
}
|
||||
return roleItemHint
|
||||
case roleErrRoleAlreadyExists:
|
||||
if operation == roleOperationCreate {
|
||||
return "choose a different --role-id or omit --role-id to let the server generate one"
|
||||
}
|
||||
case roleErrRoleLimitExceeded:
|
||||
if operation == roleOperationCreate {
|
||||
return "delete an unused app role before creating another role"
|
||||
}
|
||||
case roleErrInvalidRoleName:
|
||||
if operation == roleOperationCreate || operation == roleOperationUpdate {
|
||||
return "adjust --name to a non-empty value accepted by the role service"
|
||||
}
|
||||
case roleErrInvalidRoleDescription:
|
||||
if operation == roleOperationCreate || operation == roleOperationUpdate {
|
||||
return "adjust --description to a value accepted by the role service"
|
||||
}
|
||||
case roleErrUnsupportedMemberType:
|
||||
if operation == roleOperationMemberList {
|
||||
return "use --member-type user, department, or chat, or omit --member-type to list all member types"
|
||||
}
|
||||
case roleErrInvalidMemberID:
|
||||
if operation == roleOperationMatchList {
|
||||
return "resolve the target user to an open_id and retry with --user-id <open_id>"
|
||||
}
|
||||
if operation == roleOperationMemberAdd || operation == roleOperationMemberRemove {
|
||||
return roleMemberHint
|
||||
}
|
||||
case roleErrUserLimitExceeded:
|
||||
if operation == roleOperationMemberAdd {
|
||||
return "reduce the users being added with --users, or remove unused user members before retrying"
|
||||
}
|
||||
case roleErrDepartmentLimitExceeded:
|
||||
if operation == roleOperationMemberAdd {
|
||||
return "reduce the departments being added with --departments, or remove unused department members before retrying"
|
||||
}
|
||||
case roleErrChatLimitExceeded:
|
||||
if operation == roleOperationMemberAdd {
|
||||
return "reduce the chats being added with --chats, or remove unused chat members before retrying"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func roleCollectionItem(item interface{}, collection string, index int) (map[string]interface{}, string, error) {
|
||||
role, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, "", invalidRoleCollectionResponse("%s item %d must be an object", collection, index)
|
||||
}
|
||||
rawRoleID, exists := role["role_id"]
|
||||
roleID, stringOK := rawRoleID.(string)
|
||||
roleID = strings.TrimSpace(roleID)
|
||||
if !exists || !stringOK || roleID == "" {
|
||||
return nil, "", invalidRoleCollectionResponse("%s item %d must contain a non-empty string role_id", collection, index)
|
||||
}
|
||||
rawName, exists := role["name"]
|
||||
name, stringOK := rawName.(string)
|
||||
if !exists || !stringOK || strings.TrimSpace(name) == "" {
|
||||
return nil, "", invalidRoleCollectionResponse("%s item %d must contain a non-empty string name", collection, index)
|
||||
}
|
||||
return role, roleID, nil
|
||||
}
|
||||
|
||||
func validateRoleCollection(items []interface{}, collection string) error {
|
||||
for index, item := range items {
|
||||
if _, _, err := roleCollectionItem(item, collection, index); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func invalidRoleCollectionResponse(format string, args ...interface{}) error {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, format, args...).
|
||||
WithHint("retry the read; do not treat missing or malformed role data as an empty or complete result")
|
||||
}
|
||||
|
||||
func buildRoleMemberGroups(usersCSV, departmentsCSV, chatsCSV string) (roleMemberGroups, error) {
|
||||
users, err := splitRoleMemberCSV(usersCSV, "--users")
|
||||
if err != nil {
|
||||
return roleMemberGroups{}, err
|
||||
}
|
||||
departments, err := splitRoleMemberCSV(departmentsCSV, "--departments")
|
||||
if err != nil {
|
||||
return roleMemberGroups{}, err
|
||||
}
|
||||
chats, err := splitRoleMemberCSV(chatsCSV, "--chats")
|
||||
if err != nil {
|
||||
return roleMemberGroups{}, err
|
||||
}
|
||||
groups := roleMemberGroups{
|
||||
Users: users,
|
||||
Departments: departments,
|
||||
Chats: chats,
|
||||
}
|
||||
total := len(groups.Users) + len(groups.Departments) + len(groups.Chats)
|
||||
if total == 0 {
|
||||
reason := "provide at least one of --users, --departments, or --chats"
|
||||
return groups, appsValidationError("at least one of --users, --departments, or --chats is required").
|
||||
WithParams(
|
||||
appsInvalidParam("--users", reason),
|
||||
appsInvalidParam("--departments", reason),
|
||||
appsInvalidParam("--chats", reason),
|
||||
).
|
||||
WithHint("resolve names to IDs first, then pass --users open_id, --departments open_department_id, or --chats open_chat_id")
|
||||
}
|
||||
if total > maxRoleMembers {
|
||||
return groups, appsValidationError("role members cannot exceed %d", maxRoleMembers).
|
||||
WithParams(roleMemberLimitParams(groups)...).
|
||||
WithHint(fmt.Sprintf("reduce the atomic request to at most %d members; the CLI does not split member writes automatically", maxRoleMembers))
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func buildRoleMemberBody(groups roleMemberGroups) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if len(groups.Users) > 0 {
|
||||
body["users"] = groups.Users
|
||||
}
|
||||
if len(groups.Departments) > 0 {
|
||||
body["departments"] = groups.Departments
|
||||
}
|
||||
if len(groups.Chats) > 0 {
|
||||
body["chats"] = groups.Chats
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func roleMemberLimitParams(groups roleMemberGroups) []errs.InvalidParam {
|
||||
reason := fmt.Sprintf("combined role member count exceeds %d", maxRoleMembers)
|
||||
params := make([]errs.InvalidParam, 0, len(roleMemberKinds))
|
||||
if len(groups.Users) > 0 {
|
||||
params = append(params, appsInvalidParam("--users", reason))
|
||||
}
|
||||
if len(groups.Departments) > 0 {
|
||||
params = append(params, appsInvalidParam("--departments", reason))
|
||||
}
|
||||
if len(groups.Chats) > 0 {
|
||||
params = append(params, appsInvalidParam("--chats", reason))
|
||||
}
|
||||
return params
|
||||
}
|
||||
447
shortcuts/apps/apps_role_common_test.go
Normal file
447
shortcuts/apps/apps_role_common_test.go
Normal file
@@ -0,0 +1,447 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newRoleRCtx(t *testing.T, flagDefs map[string]string, flags map[string]string) (*common.RuntimeContext, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ToLower(t.Name()),
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_test",
|
||||
}
|
||||
factory, stdoutBuf, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
cmd := &cobra.Command{Use: "test-role"}
|
||||
cmd.SetContext(context.Background())
|
||||
for name, typ := range flagDefs {
|
||||
switch typ {
|
||||
case "bool":
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
case "int":
|
||||
cmd.Flags().Int(name, 0, "")
|
||||
case "string_array":
|
||||
cmd.Flags().StringArray(name, nil, "")
|
||||
default:
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
}
|
||||
for name, val := range flags {
|
||||
if err := cmd.Flags().Set(name, val); err != nil {
|
||||
t.Fatalf("set flag %q = %q: %v", name, val, err)
|
||||
}
|
||||
}
|
||||
rctx := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, core.AsUser)
|
||||
return rctx, stdoutBuf, reg
|
||||
}
|
||||
|
||||
func assertRoleValidationParam(t *testing.T, err error, param string) *errs.Problem {
|
||||
t.Helper()
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %#v, want typed problem", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("category = %q, want validation", problem.Category)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want invalid_argument", problem.Subtype)
|
||||
}
|
||||
var validation *errs.ValidationError
|
||||
if !errors.As(err, &validation) {
|
||||
t.Fatalf("err = %#v, want validation error", err)
|
||||
}
|
||||
if validation.Param != param {
|
||||
t.Fatalf("param = %q, want %s", validation.Param, param)
|
||||
}
|
||||
return problem
|
||||
}
|
||||
|
||||
func assertRoleValidationParams(t *testing.T, err error, params ...string) *errs.Problem {
|
||||
t.Helper()
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %#v, want typed problem", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %+v, want validation/invalid_argument", problem)
|
||||
}
|
||||
var validation *errs.ValidationError
|
||||
if !errors.As(err, &validation) {
|
||||
t.Fatalf("err = %#v, want validation error", err)
|
||||
}
|
||||
if validation.Param != "" {
|
||||
t.Fatalf("param = %q, want omitted for multi-parameter constraint", validation.Param)
|
||||
}
|
||||
if len(validation.Params) != len(params) {
|
||||
t.Fatalf("params = %#v, want %v", validation.Params, params)
|
||||
}
|
||||
for index, want := range params {
|
||||
if validation.Params[index].Name != want || validation.Params[index].Reason == "" {
|
||||
t.Fatalf("params[%d] = %#v, want name=%q with a reason", index, validation.Params[index], want)
|
||||
}
|
||||
}
|
||||
return problem
|
||||
}
|
||||
|
||||
func TestBuildRolePageParams_DefaultAndChanged(t *testing.T) {
|
||||
rctx, _, _ := newRoleRCtx(t, map[string]string{
|
||||
"page-size": "int",
|
||||
"page-token": "string",
|
||||
}, map[string]string{})
|
||||
params, err := buildRolePageParams(rctx)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRolePageParams() = %v", err)
|
||||
}
|
||||
if params["limit"] != defaultRolePageSize || params["offset"] != 0 {
|
||||
t.Fatalf("params = %#v, want limit=%d offset=0", params, defaultRolePageSize)
|
||||
}
|
||||
|
||||
rctx, _, _ = newRoleRCtx(t, map[string]string{
|
||||
"page-size": "int",
|
||||
"page-token": "string",
|
||||
}, map[string]string{"page-size": "20", "page-token": "40"})
|
||||
params, err = buildRolePageParams(rctx)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRolePageParams(changed) = %v", err)
|
||||
}
|
||||
if params["limit"] != 20 || params["offset"] != 40 {
|
||||
t.Fatalf("params = %#v, want limit=20 offset=40", params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRolePageParams_RejectsInvalidToken(t *testing.T) {
|
||||
rctx, _, _ := newRoleRCtx(t, map[string]string{
|
||||
"page-size": "int",
|
||||
"page-token": "string",
|
||||
}, map[string]string{"page-token": "abc"})
|
||||
_, err := buildRolePageParams(rctx)
|
||||
assertRoleValidationParam(t, err, "--page-token")
|
||||
}
|
||||
|
||||
func TestBuildRolePageParams_RejectsPageSizeOverMax(t *testing.T) {
|
||||
rctx, _, _ := newRoleRCtx(t, map[string]string{
|
||||
"page-size": "int",
|
||||
"page-token": "string",
|
||||
}, map[string]string{"page-size": "101"})
|
||||
_, err := buildRolePageParams(rctx)
|
||||
assertRoleValidationParam(t, err, "--page-size")
|
||||
}
|
||||
|
||||
func TestValidateOptionalRoleID(t *testing.T) {
|
||||
for _, good := range []string{"", " role_001 ", "Role-ABC", "abc123", strings.Repeat("a", 64)} {
|
||||
if err := validateOptionalRoleID(good); err != nil {
|
||||
t.Fatalf("validateOptionalRoleID(%q) = %v", good, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"bad/role", "bad role", strings.Repeat("a", 65)} {
|
||||
err := validateOptionalRoleID(bad)
|
||||
problem := assertRoleValidationParam(t, err, "--role-id")
|
||||
if !strings.Contains(problem.Hint, "omit --role-id") {
|
||||
t.Fatalf("hint = %q, want create-specific omit guidance", problem.Hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleFlagHelpersTrim(t *testing.T) {
|
||||
rctx, _, _ := newRoleRCtx(t, map[string]string{
|
||||
"app-id": "string",
|
||||
"role-id": "string",
|
||||
}, map[string]string{"app-id": " app_1 ", "role-id": " role_1 "})
|
||||
if got := roleAppID(rctx); got != "app_1" {
|
||||
t.Fatalf("roleAppID() = %q, want app_1", got)
|
||||
}
|
||||
if got := roleID(rctx); got != "role_1" {
|
||||
t.Fatalf("roleID() = %q, want role_1", got)
|
||||
}
|
||||
if err := validateRoleID(rctx); err != nil {
|
||||
t.Fatalf("validateRoleID() = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRoleAppIDRejectsEmpty(t *testing.T) {
|
||||
rctx, _, _ := newRoleRCtx(t, map[string]string{
|
||||
"app-id": "string",
|
||||
}, map[string]string{})
|
||||
problem := assertRoleValidationParam(t, validateRoleAppID(rctx), "--app-id")
|
||||
if problem.Message != "--app-id is required" {
|
||||
t.Fatalf("message = %q, want --app-id is required", problem.Message)
|
||||
}
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("hint is empty, want recovery guidance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRoleAppIDRejectsPathSegmentUnsafeChars(t *testing.T) {
|
||||
for _, appID := range []string{"app/bad", `app\bad`, "app bad", "app\u00a0bad", "app\nbad", "app\u0000bad"} {
|
||||
rctx, _, _ := newRoleRCtx(t, map[string]string{
|
||||
"app-id": "string",
|
||||
}, map[string]string{"app-id": appID})
|
||||
assertRoleValidationParam(t, validateRoleAppID(rctx), "--app-id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRoleAppIDRejectsLarkCredentialAppID(t *testing.T) {
|
||||
rctx, _, _ := newRoleRCtx(t, map[string]string{
|
||||
"app-id": "string",
|
||||
}, map[string]string{"app-id": "cli_app"})
|
||||
assertRoleValidationParam(t, validateRoleAppID(rctx), "--app-id")
|
||||
}
|
||||
|
||||
func TestValidateRoleAppIDRequiresMiaodaPrefix(t *testing.T) {
|
||||
for _, appID := range []string{"app", "app_", "miaoda_123", "plain"} {
|
||||
rctx, _, _ := newRoleRCtx(t, map[string]string{
|
||||
"app-id": "string",
|
||||
}, map[string]string{"app-id": appID})
|
||||
problem := assertRoleValidationParam(t, validateRoleAppID(rctx), "--app-id")
|
||||
if !strings.Contains(problem.Message, "starting with app_") {
|
||||
t.Fatalf("appID=%q message=%q, want app_ guidance", appID, problem.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRoleIDRejectsInvalidRequiredRoleID(t *testing.T) {
|
||||
rctx, _, _ := newRoleRCtx(t, map[string]string{
|
||||
"app-id": "string",
|
||||
"role-id": "string",
|
||||
}, map[string]string{"app-id": "app_x", "role-id": "bad/role"})
|
||||
problem := assertRoleValidationParam(t, validateRoleID(rctx), "--role-id")
|
||||
if strings.Contains(problem.Hint, "omit --role-id") || !strings.Contains(problem.Hint, "+role-list") {
|
||||
t.Fatalf("hint = %q, want existing-role resolution guidance", problem.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRoleIDRejectsMissingRequiredRoleID(t *testing.T) {
|
||||
rctx, _, _ := newRoleRCtx(t, map[string]string{
|
||||
"app-id": "string",
|
||||
"role-id": "string",
|
||||
}, map[string]string{"app-id": "app_x"})
|
||||
problem := assertRoleValidationParam(t, validateRoleID(rctx), "--role-id")
|
||||
if problem.Message != "--role-id is required" {
|
||||
t.Fatalf("message = %q, want --role-id is required", problem.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRoleMemberGroupsAndBody(t *testing.T) {
|
||||
groups, err := buildRoleMemberGroups(" ou_a,ou_b ", " od-a ", " oc_a ")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRoleMemberGroups() = %v", err)
|
||||
}
|
||||
if len(groups.Users) != 2 || len(groups.Departments) != 1 || len(groups.Chats) != 1 {
|
||||
t.Fatalf("groups = %#v", groups)
|
||||
}
|
||||
body := buildRoleMemberBody(groups)
|
||||
assertJSONEquivalent(t, body, map[string]interface{}{
|
||||
"users": []interface{}{"ou_a", "ou_b"},
|
||||
"departments": []interface{}{"od-a"},
|
||||
"chats": []interface{}{"oc_a"},
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildRoleMemberGroupsRejectsEmpty(t *testing.T) {
|
||||
_, err := buildRoleMemberGroups(" , ", "", "")
|
||||
assertRoleValidationParams(t, err, "--users", "--departments", "--chats")
|
||||
}
|
||||
|
||||
func TestBuildRoleMemberGroupsRejectsInvalidMemberIDWithSourceParam(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
users string
|
||||
departments string
|
||||
chats string
|
||||
wantParam string
|
||||
}{
|
||||
{name: "users slash", users: "ou/bad", wantParam: "--users"},
|
||||
{name: "users email", users: "alice@example.com", wantParam: "--users"},
|
||||
{name: "users wrong prefix", users: "user_123", wantParam: "--users"},
|
||||
{name: "users prefix only", users: "ou_", wantParam: "--users"},
|
||||
{name: "departments wrong prefix", departments: "ou_user", wantParam: "--departments"},
|
||||
{name: "departments prefix only", departments: "od-", wantParam: "--departments"},
|
||||
{name: "legacy departments prefix", departments: "od_department", wantParam: "--departments"},
|
||||
{name: "chats wrong prefix", chats: "od-department", wantParam: "--chats"},
|
||||
{name: "chats prefix only", chats: "oc_", wantParam: "--chats"},
|
||||
{
|
||||
name: "departments",
|
||||
departments: "od-bad value",
|
||||
wantParam: "--departments",
|
||||
},
|
||||
{
|
||||
name: "chats",
|
||||
chats: "oc?bad",
|
||||
wantParam: "--chats",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := buildRoleMemberGroups(tt.users, tt.departments, tt.chats)
|
||||
assertRoleValidationParam(t, err, tt.wantParam)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRoleMemberGroupsRejectsMoreThanMax(t *testing.T) {
|
||||
users := make([]string, maxRoleMembers+1)
|
||||
for i := range users {
|
||||
users[i] = "ou_test"
|
||||
}
|
||||
_, err := buildRoleMemberGroups(strings.Join(users, ","), "", "")
|
||||
assertRoleValidationParams(t, err, "--users")
|
||||
}
|
||||
|
||||
func TestBuildRoleMemberGroupsRejectsMoreThanMaxOnlyChats(t *testing.T) {
|
||||
chats := make([]string, maxRoleMembers+1)
|
||||
for i := range chats {
|
||||
chats[i] = "oc_test"
|
||||
}
|
||||
_, err := buildRoleMemberGroups("", "", strings.Join(chats, ","))
|
||||
problem := assertRoleValidationParams(t, err, "--chats")
|
||||
if !strings.Contains(problem.Message, "role members cannot exceed 100") {
|
||||
t.Fatalf("message = %q, want role members limit", problem.Message)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, "does not split") || !strings.Contains(problem.Hint, "atomic request") {
|
||||
t.Fatalf("hint = %q, want no automatic batching guidance", problem.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRoleMemberGroupsOverflowNamesEveryContributingFlag(t *testing.T) {
|
||||
users := strings.TrimSuffix(strings.Repeat("ou_user,", 60), ",")
|
||||
chats := strings.TrimSuffix(strings.Repeat("oc_chat,", 41), ",")
|
||||
_, err := buildRoleMemberGroups(users, "", chats)
|
||||
assertRoleValidationParams(t, err, "--users", "--chats")
|
||||
}
|
||||
|
||||
func TestRoleMemberKindsAreCompleteAndStable(t *testing.T) {
|
||||
want := []roleMemberKind{
|
||||
{memberType: "user", dataKey: "users", flagName: "--users", prefix: "ou_"},
|
||||
{memberType: "department", dataKey: "departments", flagName: "--departments", prefix: "od-"},
|
||||
{memberType: "chat", dataKey: "chats", flagName: "--chats", prefix: "oc_"},
|
||||
}
|
||||
if len(roleMemberKinds) != len(want) {
|
||||
t.Fatalf("roleMemberKinds = %#v, want %#v", roleMemberKinds, want)
|
||||
}
|
||||
for index := range want {
|
||||
if roleMemberKinds[index] != want[index] {
|
||||
t.Fatalf("roleMemberKinds[%d] = %#v, want %#v", index, roleMemberKinds[index], want[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleDisplayValueSanitizesAndFlattens(t *testing.T) {
|
||||
got := roleDisplayValue(" Admin\n\x1b[31mred\x1b[0m\tvalue ")
|
||||
if got != "Admin red value" {
|
||||
t.Fatalf("roleDisplayValue() = %q, want flattened safe text", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleNextPageToken(t *testing.T) {
|
||||
if got := roleNextPageToken(40, 20, true); got != "60" {
|
||||
t.Fatalf("roleNextPageToken(hasMore) = %q, want 60", got)
|
||||
}
|
||||
if got := roleNextPageToken(40, 20, false); got != "" {
|
||||
t.Fatalf("roleNextPageToken(!hasMore) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithRoleErrorHintUsesDocumentedRecoveryAndPreservesEnvelope(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
code int
|
||||
operation roleErrorOperation
|
||||
wantHint string
|
||||
forbid string
|
||||
}{
|
||||
{name: "invalid parameters", code: roleErrInvalidParameters, operation: roleOperationList, wantHint: roleAppHint},
|
||||
{name: "administrator required", code: roleErrAdminRequired, operation: roleOperationList, wantHint: "app administrator"},
|
||||
{name: "administrator or developer required", code: roleErrManagerRequired, operation: roleOperationGet, wantHint: "administrator or app developer"},
|
||||
{name: "invalid create role id", code: roleErrInvalidRoleID, operation: roleOperationCreate, wantHint: "omit --role-id"},
|
||||
{name: "role missing", code: roleErrRoleNotFound, operation: roleOperationGet, wantHint: "+role-list"},
|
||||
{name: "stale match role", code: roleErrRoleNotFound, operation: roleOperationMatchList, wantHint: "may no longer be valid", forbid: "--role-id"},
|
||||
{name: "duplicate role id", code: roleErrRoleAlreadyExists, operation: roleOperationCreate, wantHint: "different --role-id"},
|
||||
{name: "role limit", code: roleErrRoleLimitExceeded, operation: roleOperationCreate, wantHint: "delete an unused app role"},
|
||||
{name: "invalid role name", code: roleErrInvalidRoleName, operation: roleOperationUpdate, wantHint: "adjust --name"},
|
||||
{name: "invalid role description", code: roleErrInvalidRoleDescription, operation: roleOperationUpdate, wantHint: "adjust --description"},
|
||||
{name: "unsupported member type", code: roleErrUnsupportedMemberType, operation: roleOperationMemberList, wantHint: "user, department, or chat"},
|
||||
{name: "invalid member id", code: roleErrInvalidMemberID, operation: roleOperationMemberAdd, wantHint: "member IDs"},
|
||||
{name: "invalid match target", code: roleErrInvalidMemberID, operation: roleOperationMatchList, wantHint: "--user-id", forbid: "--role-id"},
|
||||
{name: "user quota", code: roleErrUserLimitExceeded, operation: roleOperationMemberAdd, wantHint: "reduce the users"},
|
||||
{name: "department quota", code: roleErrDepartmentLimitExceeded, operation: roleOperationMemberAdd, wantHint: "reduce the departments"},
|
||||
{name: "chat quota", code: roleErrChatLimitExceeded, operation: roleOperationMemberAdd, wantHint: "reduce the chats"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := errclass.BuildAPIError(map[string]any{
|
||||
"code": tt.code,
|
||||
"msg": "role request failed",
|
||||
"log_id": "log-role-hint",
|
||||
}, errclass.ClassifyContext{Identity: "user"})
|
||||
err = withRoleErrorHint(err, tt.operation)
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %#v, want typed problem", err)
|
||||
}
|
||||
if problem.Code != tt.code || problem.LogID != "log-role-hint" || problem.Retryable {
|
||||
t.Fatalf("problem envelope changed: %+v", problem)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, tt.wantHint) {
|
||||
t.Fatalf("hint = %q, want substring %q", problem.Hint, tt.wantHint)
|
||||
}
|
||||
if tt.forbid != "" && strings.Contains(problem.Hint, tt.forbid) {
|
||||
t.Fatalf("hint = %q, must not contain %q", problem.Hint, tt.forbid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithRoleErrorHintPreservesServerDetail(t *testing.T) {
|
||||
err := errclass.BuildAPIError(map[string]any{
|
||||
"code": roleErrInvalidRoleName,
|
||||
"msg": "invalid role name",
|
||||
"error": map[string]any{
|
||||
"details": []any{map[string]any{"value": "name exceeds the service limit"}},
|
||||
},
|
||||
}, errclass.ClassifyContext{Identity: "user"})
|
||||
err = withRoleErrorHint(err, roleOperationCreate)
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %#v, want typed problem", err)
|
||||
}
|
||||
for _, want := range []string{"name exceeds the service limit", "adjust --name"} {
|
||||
if !strings.Contains(problem.Hint, want) {
|
||||
t.Fatalf("hint = %q, want %q", problem.Hint, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithRoleErrorHintPreservesAuthorizationDetail(t *testing.T) {
|
||||
var err error = errs.NewPermissionError(errs.SubtypePermissionDenied, "administrator access required").
|
||||
WithCode(roleErrAdminRequired).
|
||||
WithHint("server detail: only owners may change this app")
|
||||
err = withRoleErrorHint(err, roleOperationUpdate)
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %#v, want typed problem", err)
|
||||
}
|
||||
for _, want := range []string{"server detail: only owners", "ask an app administrator"} {
|
||||
if !strings.Contains(problem.Hint, want) {
|
||||
t.Fatalf("hint = %q, want %q", problem.Hint, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
611
shortcuts/apps/apps_role_member.go
Normal file
611
shortcuts/apps/apps_role_member.go
Normal file
@@ -0,0 +1,611 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsRoleMemberList lists members of an app role.
|
||||
var AppsRoleMemberList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+role-member-list",
|
||||
Description: "List app role members",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +role-member-list --app-id <app_id> --role-id <role_id>",
|
||||
"Example: lark-cli apps +role-member-list --app-id <app_id> --role-id <role_id> --member-type user",
|
||||
"When only one member type is requested, pass --member-type user|department|chat instead of filtering the full response",
|
||||
"--member-type returns only the selected member field; omitted fields are unknown, so omit the flag for pre/post-write baselines",
|
||||
"--format table renders the CLI-native member_type/member_id table; this command has no --limit or --page-size flag",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
|
||||
{Name: "role-id", Desc: roleIDRequiredDesc, Required: true},
|
||||
{Name: "member-type", Desc: "filter member type", Enum: []string{"user", "department", "chat"}},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if err := validateRoleID(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := buildRoleMemberListParams(rctx)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
// Validate already ran and called buildRoleMemberListParams; error is impossible here.
|
||||
params, _ := buildRoleMemberListParams(rctx)
|
||||
return common.NewDryRunAPI().
|
||||
GET(roleMemberListURL(rctx)).
|
||||
Desc("List app role members").
|
||||
Params(params)
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
params, err := buildRoleMemberListParams(rctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", roleMemberListURL(rctx), params, nil)
|
||||
memberType, _ := params["member_type"].(string)
|
||||
if shouldRetryRoleMemberListWithoutFilter(err, memberType) {
|
||||
fmt.Fprintln(rctx.IO().ErrOut, "warning: the server rejected chat member filtering; retried without the filter and returned only the chats field. Omit --member-type for a complete member baseline.")
|
||||
data, err = rctx.CallAPITyped("GET", roleMemberListURL(rctx), nil, nil)
|
||||
}
|
||||
if err != nil {
|
||||
return withRoleErrorHint(err, roleOperationMemberList)
|
||||
}
|
||||
data, err = normalizeRoleMemberListData(data, memberType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if memberType != "" {
|
||||
fmt.Fprintf(
|
||||
rctx.IO().ErrOut,
|
||||
"warning: --member-type=%s returns only the selected member field; omitted member fields are unknown. Omit --member-type for a complete member baseline.\n",
|
||||
memberType,
|
||||
)
|
||||
}
|
||||
out := roleMemberListOutputData(rctx, data)
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderRoleMemberListPretty(w, data)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// AppsRoleMemberAdd adds members to an app role.
|
||||
var AppsRoleMemberAdd = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+role-member-add",
|
||||
Description: "Add app role members",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +role-member-add --app-id <app_id> --role-id <role_id> --users ou_x",
|
||||
"Example: lark-cli apps +role-member-add --app-id <app_id> --role-id <role_id> --users ou_x,ou_y --departments od-x --chats oc_x",
|
||||
"Resolve every name first, then add all resolved users (ou_), departments (od-), and chats (oc_) in one call using the three type-specific flags; if any resolution fails, stop without a partial write",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
|
||||
{Name: "role-id", Desc: roleIDRequiredDesc, Required: true},
|
||||
{Name: "users", Desc: "comma-separated user open IDs; do not pass names or emails"},
|
||||
{Name: "departments", Desc: "comma-separated open_department_id values"},
|
||||
{Name: "chats", Desc: "comma-separated open_chat_id values"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if err := validateRoleID(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := buildRoleMemberGroups(rctx.Str("users"), rctx.Str("departments"), rctx.Str("chats"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
// Validate already ran and called buildRoleMemberAddBody; error is impossible here.
|
||||
body, _, _ := buildRoleMemberAddBody(rctx)
|
||||
return common.NewDryRunAPI().
|
||||
POST(roleMemberAddURL(rctx)).
|
||||
Desc("Add app role members").
|
||||
Body(body)
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
body, _, err := buildRoleMemberAddBody(rctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("POST", roleMemberAddURL(rctx), nil, body)
|
||||
if err != nil {
|
||||
return withRoleErrorHint(err, roleOperationMemberAdd)
|
||||
}
|
||||
data, err = normalizeRoleMemberMutationData(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderRoleMemberMutationPretty(w, data)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// AppsRoleMemberRemove removes members from an app role.
|
||||
var AppsRoleMemberRemove = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+role-member-remove",
|
||||
Description: "Remove app role members",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +role-member-remove --app-id <app_id> --role-id <role_id> --users ou_x --yes",
|
||||
"Example: lark-cli apps +role-member-remove --app-id <app_id> --role-id <role_id> --all --yes",
|
||||
"When the user names a member, resolve and verify that exact name before writing; if lookup fails, stop and never infer that the role's only current member is the target",
|
||||
"--all clears members but does not delete the role; after a confirmed --all operation, use an unfiltered +role-member-list to verify users, departments, and chats are empty",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
|
||||
{Name: "role-id", Desc: roleIDRequiredDesc, Required: true},
|
||||
{Name: "users", Desc: "comma-separated user open IDs; do not pass names or emails"},
|
||||
{Name: "departments", Desc: "comma-separated open_department_id values"},
|
||||
{Name: "chats", Desc: "comma-separated open_chat_id values"},
|
||||
{Name: "all", Type: "bool", Desc: "remove all members from the role"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if err := validateRoleID(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err := buildRoleMemberRemoveBody(rctx)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
// Validate already ran and called buildRoleMemberRemoveBody; error is impossible here.
|
||||
body, _, _ := buildRoleMemberRemoveBody(rctx)
|
||||
return common.NewDryRunAPI().
|
||||
POST(roleMemberRemoveURL(rctx)).
|
||||
Desc("Remove app role members").
|
||||
Body(body)
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
body, _, err := buildRoleMemberRemoveBody(rctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("POST", roleMemberRemoveURL(rctx), nil, body)
|
||||
if err != nil {
|
||||
return withRoleErrorHint(err, roleOperationMemberRemove)
|
||||
}
|
||||
data, err = normalizeRoleMemberMutationData(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderRoleMemberMutationPretty(w, data)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// AppsRoleMatchList lists roles matching a user in an app.
|
||||
var AppsRoleMatchList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+role-match-list",
|
||||
Description: "List app roles matching a user",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +role-match-list --app-id <app_id> --user-id <user_open_id>",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
|
||||
{Name: "user-id", Desc: roleUserIDRequiredDesc, Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if err := validateRoleAppID(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := roleMatchTargetUserID(rctx)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
// Validate already ran and called buildRoleMatchListBody; error is impossible here.
|
||||
body, _ := buildRoleMatchListBody(rctx)
|
||||
return common.NewDryRunAPI().
|
||||
POST(roleMatchListURL(rctx)).
|
||||
Desc("List app role matches").
|
||||
Body(body)
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
body, err := buildRoleMatchListBody(rctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("POST", roleMatchListURL(rctx), nil, body)
|
||||
if err != nil {
|
||||
return withRoleErrorHint(err, roleOperationMatchList)
|
||||
}
|
||||
out, err := normalizeRoleMatchListData(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderRoleMatchListPretty(w, common.GetSlice(out, "roles"))
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func roleMemberListURL(rctx *common.RuntimeContext) string {
|
||||
return fmt.Sprintf(roleMemberListPath,
|
||||
validate.EncodePathSegment(roleAppID(rctx)),
|
||||
validate.EncodePathSegment(roleID(rctx)),
|
||||
)
|
||||
}
|
||||
|
||||
func roleMemberAddURL(rctx *common.RuntimeContext) string {
|
||||
return fmt.Sprintf(roleMemberAddPath,
|
||||
validate.EncodePathSegment(roleAppID(rctx)),
|
||||
validate.EncodePathSegment(roleID(rctx)),
|
||||
)
|
||||
}
|
||||
|
||||
func roleMemberRemoveURL(rctx *common.RuntimeContext) string {
|
||||
return fmt.Sprintf(roleMemberRemovePath,
|
||||
validate.EncodePathSegment(roleAppID(rctx)),
|
||||
validate.EncodePathSegment(roleID(rctx)),
|
||||
)
|
||||
}
|
||||
|
||||
func roleMatchListURL(rctx *common.RuntimeContext) string {
|
||||
return fmt.Sprintf(roleMatchListPath, validate.EncodePathSegment(roleAppID(rctx)))
|
||||
}
|
||||
|
||||
func buildRoleMemberListParams(rctx *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
params := map[string]interface{}{}
|
||||
if memberType := strings.TrimSpace(rctx.Str("member-type")); memberType != "" {
|
||||
if _, ok := roleMemberKindForType(memberType); !ok {
|
||||
return nil, appsValidationParamError("--member-type", "--member-type must be one of user, department, or chat").
|
||||
WithHint("omit --member-type to list all member types")
|
||||
}
|
||||
params["member_type"] = memberType
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
func shouldRetryRoleMemberListWithoutFilter(err error, memberType string) bool {
|
||||
if err == nil || memberType != "chat" {
|
||||
return false
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if problem.Code == roleErrUnsupportedMemberType || problem.Code == 400004040 {
|
||||
return true
|
||||
}
|
||||
return problem.Code == 2 && strings.Contains(strings.ToLower(problem.Message), "member_type")
|
||||
}
|
||||
|
||||
func normalizeRoleMemberListData(data map[string]interface{}, memberType string) (map[string]interface{}, error) {
|
||||
if data == nil {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"role member response data must be an object",
|
||||
).WithHint("retry the complete member read; do not treat missing, null, or non-object data as an empty role")
|
||||
}
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range data {
|
||||
out[k] = v
|
||||
}
|
||||
// The role service uses an exact empty data object when the requested member
|
||||
// view is empty. For a filtered request, that proves only the selected group
|
||||
// is empty; non-selected groups must remain omitted rather than being
|
||||
// synthesized as empty.
|
||||
if len(data) == 0 {
|
||||
if memberType != "" {
|
||||
kind, _ := roleMemberKindForType(memberType)
|
||||
out[kind.dataKey] = []string{}
|
||||
return out, nil
|
||||
}
|
||||
for _, kind := range roleMemberKinds {
|
||||
out[kind.dataKey] = []string{}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if memberType != "" {
|
||||
selectedKind, _ := roleMemberKindForType(memberType)
|
||||
values, err := parseRoleMemberIDs(data, selectedKind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, kind := range roleMemberKinds {
|
||||
if kind.memberType != memberType {
|
||||
delete(out, kind.dataKey)
|
||||
}
|
||||
}
|
||||
out[selectedKind.dataKey] = values
|
||||
return out, nil
|
||||
}
|
||||
for _, kind := range roleMemberKinds {
|
||||
values, err := parseRoleMemberIDs(data, kind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[kind.dataKey] = values
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseRoleMemberIDs(data map[string]interface{}, kind roleMemberKind) ([]string, error) {
|
||||
raw, exists := data[kind.dataKey]
|
||||
if !exists {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"role member response is missing %s",
|
||||
kind.dataKey,
|
||||
).WithHint("retry the member operation; do not treat a missing member group as empty")
|
||||
}
|
||||
items, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
if stringItems, stringOK := raw.([]string); stringOK {
|
||||
items = make([]interface{}, len(stringItems))
|
||||
for index, value := range stringItems {
|
||||
items[index] = value
|
||||
}
|
||||
} else {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"role member response field %s must be an array of strings",
|
||||
kind.dataKey,
|
||||
).WithHint("retry the member operation; do not use malformed member data as a permission baseline")
|
||||
}
|
||||
}
|
||||
values := make([]string, 0, len(items))
|
||||
for index, item := range items {
|
||||
value, ok := item.(string)
|
||||
value = strings.TrimSpace(value)
|
||||
if !ok || value == "" || !strings.HasPrefix(value, kind.prefix) || len(value) == len(kind.prefix) {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"role member response field %s contains an invalid ID at index %d",
|
||||
kind.dataKey,
|
||||
index,
|
||||
).WithHint("retry the member operation; expected open IDs with the documented member-type prefix")
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func normalizeRoleMemberMutationData(data map[string]interface{}) (map[string]interface{}, error) {
|
||||
if data == nil {
|
||||
return nil, nil
|
||||
}
|
||||
out := map[string]interface{}{}
|
||||
for key, value := range data {
|
||||
out[key] = value
|
||||
}
|
||||
for _, kind := range roleMemberKinds {
|
||||
if _, exists := data[kind.dataKey]; !exists {
|
||||
continue
|
||||
}
|
||||
values, err := parseRoleMemberIDs(data, kind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[kind.dataKey] = values
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func buildRoleMemberAddBody(rctx *common.RuntimeContext) (map[string]interface{}, roleMemberGroups, error) {
|
||||
groups, err := buildRoleMemberGroups(rctx.Str("users"), rctx.Str("departments"), rctx.Str("chats"))
|
||||
if err != nil {
|
||||
return nil, groups, err
|
||||
}
|
||||
return buildRoleMemberBody(groups), groups, nil
|
||||
}
|
||||
|
||||
func buildRoleMemberRemoveBody(rctx *common.RuntimeContext) (map[string]interface{}, roleMemberGroups, error) {
|
||||
if rctx.Bool("all") {
|
||||
if hasExplicitRoleMemberFlags(rctx) {
|
||||
return nil, roleMemberGroups{}, appsValidationError("--all cannot be used with --users, --departments, or --chats").
|
||||
WithParams(roleMemberRemoveConflictParams(rctx)...).
|
||||
WithHint("use --all by itself to clear every member, or pass explicit member IDs without --all")
|
||||
}
|
||||
return map[string]interface{}{"all": true}, roleMemberGroups{}, nil
|
||||
}
|
||||
if !hasExplicitRoleMemberFlags(rctx) {
|
||||
reason := "provide member IDs or use --all"
|
||||
return nil, roleMemberGroups{}, appsValidationError("specify members to remove with --users/--departments/--chats, or use --all to clear every member").
|
||||
WithParams(
|
||||
appsInvalidParam("--users", reason),
|
||||
appsInvalidParam("--departments", reason),
|
||||
appsInvalidParam("--chats", reason),
|
||||
appsInvalidParam("--all", reason),
|
||||
).
|
||||
WithHint("pass specific member IDs (e.g. --users ou_x), or use --all to remove all members")
|
||||
}
|
||||
groups, err := buildRoleMemberGroups(rctx.Str("users"), rctx.Str("departments"), rctx.Str("chats"))
|
||||
if err != nil {
|
||||
return nil, groups, err
|
||||
}
|
||||
return buildRoleMemberBody(groups), groups, nil
|
||||
}
|
||||
|
||||
func roleMemberRemoveConflictParams(rctx *common.RuntimeContext) []errs.InvalidParam {
|
||||
reason := "cannot be combined with --all"
|
||||
params := []errs.InvalidParam{appsInvalidParam("--all", "cannot be combined with explicit member flags")}
|
||||
for _, kind := range roleMemberKinds {
|
||||
if strings.TrimSpace(rctx.Str(strings.TrimPrefix(kind.flagName, "--"))) != "" {
|
||||
params = append(params, appsInvalidParam(kind.flagName, reason))
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func hasExplicitRoleMemberFlags(rctx *common.RuntimeContext) bool {
|
||||
return strings.TrimSpace(rctx.Str("users")) != "" ||
|
||||
strings.TrimSpace(rctx.Str("departments")) != "" ||
|
||||
strings.TrimSpace(rctx.Str("chats")) != ""
|
||||
}
|
||||
|
||||
func buildRoleMatchListBody(rctx *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
targetUserID, err := roleMatchTargetUserID(rctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"target_user_id": targetUserID}, nil
|
||||
}
|
||||
|
||||
func roleMatchTargetUserID(rctx *common.RuntimeContext) (string, error) {
|
||||
raw := strings.TrimSpace(rctx.Str("user-id"))
|
||||
if raw == "" {
|
||||
return "", appsValidationParamError("--user-id", "--user-id is required").
|
||||
WithHint("resolve the user to open_id first, then pass --user-id <open_id>")
|
||||
}
|
||||
if err := validateMemberID(raw, "--user-id"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func roleMemberListOutputData(rctx *common.RuntimeContext, data map[string]interface{}) interface{} {
|
||||
switch rctx.Format {
|
||||
case "table", "csv", "ndjson":
|
||||
return roleMemberRows(data)
|
||||
default:
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
func roleMemberRows(data map[string]interface{}) []interface{} {
|
||||
rows := []interface{}{}
|
||||
addRows := func(memberType string, values []string) {
|
||||
for _, value := range values {
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"member_type": memberType,
|
||||
"member_id": value,
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, kind := range roleMemberKinds {
|
||||
addRows(kind.memberType, roleIDValues(data[kind.dataKey]))
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func normalizeRoleMatchListData(data map[string]interface{}) (map[string]interface{}, error) {
|
||||
rawRoles, exists := data["roles"]
|
||||
roles, ok := rawRoles.([]interface{})
|
||||
if !exists || !ok {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"role match response field roles must be an array",
|
||||
).WithHint("retry the user-role lookup; do not treat a missing or malformed roles field as no matches")
|
||||
}
|
||||
if err := validateRoleCollection(roles, "role match response field roles"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range data {
|
||||
out[k] = v
|
||||
}
|
||||
out["roles"] = roles
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func renderRoleMemberListPretty(w io.Writer, data map[string]interface{}) {
|
||||
renderRoleMemberGroupsPretty(w, data)
|
||||
}
|
||||
|
||||
func renderRoleMemberGroupsPretty(w io.Writer, data map[string]interface{}) {
|
||||
for _, kind := range roleMemberKinds {
|
||||
value, exists := data[kind.dataKey]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
renderRoleMemberSection(w, kind.dataKey, roleIDValues(value))
|
||||
}
|
||||
}
|
||||
|
||||
func renderRoleMemberMutationPretty(w io.Writer, data map[string]interface{}) {
|
||||
renderedGroup := false
|
||||
for _, kind := range roleMemberKinds {
|
||||
value, exists := data[kind.dataKey]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
renderRoleMemberSection(w, kind.dataKey, roleIDValues(value))
|
||||
renderedGroup = true
|
||||
}
|
||||
if !renderedGroup {
|
||||
fmt.Fprintln(w, "Role member update accepted; use +role-member-list to verify current members.")
|
||||
}
|
||||
}
|
||||
|
||||
func renderRoleMemberSection(w io.Writer, label string, values []string) {
|
||||
if len(values) == 0 {
|
||||
fmt.Fprintf(w, "%s: []\n", label)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "%s:\n", label)
|
||||
for _, value := range values {
|
||||
fmt.Fprintf(w, " - %s\n", roleDisplayValue(value))
|
||||
}
|
||||
}
|
||||
|
||||
func roleIDValues(value interface{}) []string {
|
||||
switch items := value.(type) {
|
||||
case []string:
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item = strings.TrimSpace(item); item != "" {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []interface{}:
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
v, ok := item.(string)
|
||||
if ok && strings.TrimSpace(v) != "" {
|
||||
out = append(out, strings.TrimSpace(v))
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func renderRoleMatchListPretty(w io.Writer, items []interface{}) {
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "ROLE ID\tNAME\tDESCRIPTION")
|
||||
for _, item := range items {
|
||||
role, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\n",
|
||||
roleDisplayValue(firstNonEmpty(common.GetString(role, "role_id"), common.GetString(role, "id"))),
|
||||
roleDisplayValue(common.GetString(role, "name")),
|
||||
roleDisplayValue(common.GetString(role, "description")),
|
||||
)
|
||||
}
|
||||
_ = tw.Flush()
|
||||
}
|
||||
1189
shortcuts/apps/apps_role_member_test.go
Normal file
1189
shortcuts/apps/apps_role_member_test.go
Normal file
File diff suppressed because it is too large
Load Diff
1320
shortcuts/apps/apps_role_test.go
Normal file
1320
shortcuts/apps/apps_role_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -24,13 +24,7 @@ func TestAppsTraceList_DryRunBuildsSearchTracesBody(t *testing.T) {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
@@ -73,13 +67,7 @@ func TestAppsTraceGet_DryRunBuildsGetTraceBody(t *testing.T) {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
|
||||
453
shortcuts/apps/automation_common.go
Normal file
453
shortcuts/apps/automation_common.go
Normal file
@@ -0,0 +1,453 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// automationBasePath 是触发器公网 OpenAPI 前缀。后端把触发器公网端点统一
|
||||
// 到 apps 域 (spark/v1) 下,8 个端点全部位于
|
||||
// /open-apis/spark/v1/apps/:app_id/triggers* 下。这里直接复用同包的
|
||||
// apiBasePath 而不是自定义前缀,避免误用早期的备选前缀。
|
||||
const automationBasePath = apiBasePath
|
||||
|
||||
func automationListPath(appID string) string {
|
||||
return fmt.Sprintf(automationBasePath+"/apps/%s/triggers", validate.EncodePathSegment(appID))
|
||||
}
|
||||
|
||||
func automationItemPath(appID, name string) string {
|
||||
return fmt.Sprintf(automationBasePath+"/apps/%s/triggers/%s",
|
||||
validate.EncodePathSegment(appID), validate.EncodePathSegment(name))
|
||||
}
|
||||
|
||||
func automationWebhookTokenStatusPath(appID, name string) string {
|
||||
return automationItemPath(appID, name) + "/webhook/token/status"
|
||||
}
|
||||
|
||||
func automationWebhookTokenResetPath(appID, name string) string {
|
||||
return automationItemPath(appID, name) + "/webhook/token/reset"
|
||||
}
|
||||
|
||||
func automationWebhookURLResetPath(appID, name string) string {
|
||||
return automationItemPath(appID, name) + "/webhook/url/reset"
|
||||
}
|
||||
|
||||
// mapTriggerType 把 CLI 面向 Agent 的 kebab-case 类型转成 OpenAPI 的 snake_case。
|
||||
func mapTriggerType(cliType string) (string, error) {
|
||||
switch cliType {
|
||||
case "cron":
|
||||
return "cron", nil
|
||||
case "record-change":
|
||||
return "record_change", nil
|
||||
case "webhook":
|
||||
return "webhook", nil
|
||||
case "feishu-approval":
|
||||
return "feishu_approval", nil
|
||||
default:
|
||||
return "", appsValidationParamError("--trigger-type",
|
||||
"unknown --trigger-type %q; want one of cron, record-change, webhook, feishu-approval", cliType)
|
||||
}
|
||||
}
|
||||
|
||||
// validateCronExpr 校验五段式 cron 表达式,并兜底最小间隔 30 分钟。
|
||||
// 这是给 Agent 的即时提示;后端 OpenAPI 层也会校验(ErrInvalidCronTab /
|
||||
// ErrCronIntervalTooSmall),CLI 本地拦截只为更快反馈。
|
||||
//
|
||||
// Minute field accepted forms:
|
||||
// - "N" (single value 0-59)
|
||||
// - "N,M,..." (comma list of single values; min pairwise gap incl. wrap >= 30)
|
||||
// - "*/N" (step from 0; N must be >= 30)
|
||||
//
|
||||
// Anything else (ranges like "N-M", stepped ranges like "N-M/S",
|
||||
// range shorthands like "0/10", question marks) is rejected up-front with a
|
||||
// typed --cron error. A previous version accepted "1-59/10" through the
|
||||
// fallthrough because none of the three matchers claimed it, and the caller
|
||||
// only found out the interval was 10 minutes when the backend rejected it
|
||||
// (or worse, silently accepted a schedule the operator did not intend).
|
||||
func validateCronExpr(expr string) error {
|
||||
fields := strings.Fields(strings.TrimSpace(expr))
|
||||
if len(fields) != 5 {
|
||||
return appsValidationParamError("--cron",
|
||||
"cron must have 5 fields (minute hour day month weekday), got %d in %q", len(fields), expr)
|
||||
}
|
||||
minute := fields[0]
|
||||
if minute == "*" {
|
||||
return appsValidationParamError("--cron",
|
||||
"cron minute field '*' means every minute; minimum interval is 30 minutes")
|
||||
}
|
||||
if strings.HasPrefix(minute, "*/") {
|
||||
n, err := strconv.Atoi(strings.TrimPrefix(minute, "*/"))
|
||||
if err != nil || n < 1 || n > 59 {
|
||||
return appsValidationParamError("--cron",
|
||||
"cron minute step %q must be an integer 1..59", minute)
|
||||
}
|
||||
// */N in cron expands to [0, N, 2N, ...] within 0..59, then wraps to 0
|
||||
// of the next hour. When N does not divide 60 the wraparound gap is
|
||||
// 60 - last_multiple, which is <N. For the 30-minute floor to hold on
|
||||
// every gap (in-hour AND wrap), *only* N=30 works: */30 fires at :00
|
||||
// and :30, gaps [30, 30]. */45 fires at :00 and :45, gaps [45, 15] —
|
||||
// the 15-min wraparound gap violates the floor. All 31..59 fail the
|
||||
// same way (small wraparound remainder); 1..29 fail the in-hour gap.
|
||||
if n != 30 {
|
||||
return appsValidationParamError("--cron",
|
||||
"cron step */%d produces a gap below the 30-minute minimum "+
|
||||
"(only */30 keeps every gap >=30 including the wraparound); "+
|
||||
"use */30, or an explicit list like '0,30'", n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(minute, ",") {
|
||||
parts := strings.Split(minute, ",")
|
||||
vals := make([]int, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil || n < 0 || n > 59 {
|
||||
return appsValidationParamError("--cron",
|
||||
"cron minute list entry %q must be an integer 0..59", p)
|
||||
}
|
||||
vals = append(vals, n)
|
||||
}
|
||||
if len(vals) >= 2 {
|
||||
sort.Ints(vals)
|
||||
minGap := 60
|
||||
for i := 1; i < len(vals); i++ {
|
||||
if gap := vals[i] - vals[i-1]; gap < minGap {
|
||||
minGap = gap
|
||||
}
|
||||
}
|
||||
if wrapGap := vals[0] + 60 - vals[len(vals)-1]; wrapGap < minGap {
|
||||
minGap = wrapGap
|
||||
}
|
||||
if minGap < 30 {
|
||||
return appsValidationParamError("--cron",
|
||||
"cron minute list %q has %d-min interval; minimum interval is 30 minutes", minute, minGap)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Bare single value fallthrough. Reject range/step-range/anything else so
|
||||
// forms like "1-59/10" (10-min interval) and "0/10" (10-min interval)
|
||||
// cannot bypass the 30-minute floor. The backend enforces its own cron
|
||||
// rules, but the CLI stays strict about which forms it accepts so callers
|
||||
// get an early, unambiguous error.
|
||||
if n, err := strconv.Atoi(minute); err == nil && n >= 0 && n <= 59 {
|
||||
return nil
|
||||
}
|
||||
return appsValidationParamError("--cron",
|
||||
"unsupported cron minute syntax %q; use N (0..59), N,M,... (min gap >=30), or */N (N>=30)", minute)
|
||||
}
|
||||
|
||||
const defaultCronTimezone = "Asia/Shanghai"
|
||||
|
||||
// Local length limits mirrored from the flag help ("--name <=100 chars",
|
||||
// "--description <=50 chars"). Enforcing here catches a violation before the
|
||||
// API round-trip and returns a typed --name / --description error, whereas
|
||||
// hitting the backend surfaces an opaque business error the agent has to
|
||||
// diagnose. Constants (not magic numbers) so the flag help and the check
|
||||
// share one source of truth if the backend ever renegotiates the limits.
|
||||
const (
|
||||
automationNameMaxLen = 100
|
||||
automationDescriptionMaxLen = 50
|
||||
)
|
||||
|
||||
// validateAutomationNameLen guards against a --name that would be rejected by
|
||||
// the backend on length. Empty is intentionally permitted here — the required
|
||||
// check lives in the create Validate hook (which fires first) and in Update
|
||||
// the flag is not required at all. Counts runes, not bytes: the flag help
|
||||
// documents "<=100 chars", and Chinese/emoji names would be silently rejected
|
||||
// well below the char limit if we counted UTF-8 bytes.
|
||||
func validateAutomationNameLen(name string) error {
|
||||
if n := utf8.RuneCountInString(name); n > automationNameMaxLen {
|
||||
return appsValidationParamError("--name",
|
||||
"--name must be at most %d chars, got %d", automationNameMaxLen, n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAutomationDescriptionLen guards --description length; empty passes.
|
||||
// Counts runes for the same reason as validateAutomationNameLen.
|
||||
func validateAutomationDescriptionLen(desc string) error {
|
||||
if n := utf8.RuneCountInString(desc); n > automationDescriptionMaxLen {
|
||||
return appsValidationParamError("--description",
|
||||
"--description must be at most %d chars, got %d", automationDescriptionMaxLen, n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// conditionFlagFamily maps each condition-carrying flag to the trigger-type
|
||||
// family it belongs to. Used by create/update to reject cross-type flag
|
||||
// combinations up-front (e.g. --trigger-type webhook --cron '0 9 * * *'
|
||||
// silently dropped --cron before this guard).
|
||||
//
|
||||
// --timezone is a modifier on --cron, so it lives in the cron family.
|
||||
// --description is trigger-type-agnostic and NOT in this map — it can pair
|
||||
// with any type on create and can appear alone on update.
|
||||
var conditionFlagFamily = map[string]string{
|
||||
"cron": "cron",
|
||||
"timezone": "cron",
|
||||
"table": "record-change",
|
||||
"event": "record-change",
|
||||
"fields": "record-change",
|
||||
"white-ip-list": "webhook",
|
||||
"event-type": "feishu-approval",
|
||||
"instance-status": "feishu-approval",
|
||||
"task-status": "feishu-approval",
|
||||
"approval-code": "feishu-approval",
|
||||
}
|
||||
|
||||
// flagIsSet reports whether a condition-carrying flag has a caller-provided
|
||||
// value. string and string-array types both need to be probed; a nil / empty
|
||||
// value counts as unset.
|
||||
func flagIsSet(rctx *common.RuntimeContext, name string) bool {
|
||||
if v := strings.TrimSpace(rctx.Str(name)); v != "" {
|
||||
return true
|
||||
}
|
||||
if arr := rctx.StrArray(name); len(arr) > 0 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// familiesInUse returns the set of trigger-type families whose condition flags
|
||||
// the caller has set on this invocation. A trigger has exactly one type, so
|
||||
// legitimate condition writes involve at most one family; anything else is a
|
||||
// user mistake that must not slip through to the backend.
|
||||
func familiesInUse(rctx *common.RuntimeContext) map[string]string {
|
||||
out := map[string]string{}
|
||||
for flag, family := range conditionFlagFamily {
|
||||
if flagIsSet(rctx, flag) {
|
||||
out[family] = flag
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// familiesMixedList renders a comma-separated, sorted list of families
|
||||
// currently in use for inclusion in the multi-family rejection error. Stable
|
||||
// order keeps the error message deterministic across Go's random map
|
||||
// iteration.
|
||||
func familiesMixedList(families map[string]string) string {
|
||||
names := make([]string, 0, len(families))
|
||||
for name := range families {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
// rejectCrossFamilyCondFlags rejects any condition flag that does not belong
|
||||
// to `wantFamily`. Returns a typed --<flag> error naming the first offending
|
||||
// flag encountered. Deterministic ordering (iterated over a stable slice)
|
||||
// keeps the error message reproducible for tests.
|
||||
func rejectCrossFamilyCondFlags(rctx *common.RuntimeContext, wantFamily string) error {
|
||||
// Stable iteration order for a deterministic Param on error.
|
||||
order := []string{
|
||||
"cron", "timezone",
|
||||
"table", "event", "fields",
|
||||
"white-ip-list",
|
||||
"event-type", "instance-status", "task-status", "approval-code",
|
||||
}
|
||||
for _, flag := range order {
|
||||
if conditionFlagFamily[flag] != wantFamily && flagIsSet(rctx, flag) {
|
||||
return appsValidationParamError("--"+flag,
|
||||
"--%s belongs to trigger-type %q, not %q; drop it or change --trigger-type",
|
||||
flag, conditionFlagFamily[flag], wantFamily)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// approvalStatusSets 是 feishu-approval 两种 event-type 各自的合法状态集合。
|
||||
// 后端 OpenAPI 不逐值校验 status,CLI 本地分桶校验是唯一保障。
|
||||
var approvalStatusSets = map[string]map[string]struct{}{
|
||||
"approval_instance": setOf("PENDING", "APPROVED", "REJECTED", "CANCELED", "DELETED", "REVERTED", "OVERTIME_CLOSE", "OVERTIME_RECOVER"),
|
||||
"approval_task": setOf("REVERTED", "PENDING", "APPROVED", "REJECTED", "TRANSFERRED", "ROLLBACK", "DONE", "OVERTIME_CLOSE", "OVERTIME_RECOVER"),
|
||||
}
|
||||
|
||||
func setOf(items ...string) map[string]struct{} {
|
||||
m := make(map[string]struct{}, len(items))
|
||||
for _, it := range items {
|
||||
m[it] = struct{}{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// buildCronCondition 产出 OpenAPI 层 cron_condition body。缺省时区补 Asia/Shanghai。
|
||||
func buildCronCondition(expr, tz string) (map[string]interface{}, error) {
|
||||
if err := validateCronExpr(expr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(tz) == "" {
|
||||
tz = defaultCronTimezone
|
||||
}
|
||||
return map[string]interface{}{"cron": strings.TrimSpace(expr), "timezone": tz}, nil
|
||||
}
|
||||
|
||||
// recordChangeEventSet 是 record-change 触发器合法 event 枚举。
|
||||
// 4 个值来自需求定义。CLI 本地做白名单校验,
|
||||
// 避免后端 event 字段校验缺失导致的"接受任意字符串→触发器永不触发"问题。
|
||||
var recordChangeEventSet = setOf("INSERT", "UPDATE", "UPSERT", "DELETE")
|
||||
|
||||
// buildRecordChangeCondition 产出 record_change_condition body;event 大写化。
|
||||
func buildRecordChangeCondition(table, event string, fields []string) (map[string]interface{}, error) {
|
||||
if strings.TrimSpace(table) == "" {
|
||||
return nil, appsValidationParamError("--table", "--table is required for record-change triggers")
|
||||
}
|
||||
ev := strings.ToUpper(strings.TrimSpace(event))
|
||||
if ev == "" {
|
||||
return nil, appsValidationParamError("--event", "--event is required for record-change triggers (INSERT/UPDATE/UPSERT/DELETE)")
|
||||
}
|
||||
if _, valid := recordChangeEventSet[ev]; !valid {
|
||||
return nil, appsValidationParamError("--event",
|
||||
"--event %q is not a valid record-change event; want one of INSERT, UPDATE, UPSERT, DELETE", event)
|
||||
}
|
||||
cond := map[string]interface{}{"event": ev, "table": strings.TrimSpace(table)}
|
||||
if len(fields) > 0 {
|
||||
cond["fields"] = fields
|
||||
}
|
||||
return cond, nil
|
||||
}
|
||||
|
||||
// buildWebhookCondition 产出 webhook_condition body。white_ip_list 在后端契约
|
||||
// 里是 required,因此当 CLI 侧未传 --white-ip-list 时也发一个空数组,避免后端
|
||||
// 拒收;显式空数组 `[]` 与"不限来源 IP"语义一致(呼应无鉴权公网回调告警)。
|
||||
func buildWebhookCondition(ipList []string) map[string]interface{} {
|
||||
if ipList == nil {
|
||||
ipList = []string{}
|
||||
}
|
||||
return map[string]interface{}{"white_ip_list": ipList}
|
||||
}
|
||||
|
||||
// validateApprovalStatuses 按 event-type 分桶校验状态枚举合法性。
|
||||
func validateApprovalStatuses(eventType string, statuses []string) error {
|
||||
set, ok := approvalStatusSets[eventType]
|
||||
if !ok {
|
||||
return appsValidationParamError("--event-type",
|
||||
"unknown --event-type %q; want approval_task or approval_instance", eventType)
|
||||
}
|
||||
if len(statuses) == 0 {
|
||||
flag := statusFlagFor(eventType)
|
||||
return appsValidationParamError("--"+flag,
|
||||
"--%s is required for event-type %q (at least one status)", flag, eventType)
|
||||
}
|
||||
for _, s := range statuses {
|
||||
if _, valid := set[strings.ToUpper(strings.TrimSpace(s))]; !valid {
|
||||
// 列出该 event-type 的合法状态集合,便于 Agent 修正。
|
||||
return appsValidationParamError("--"+statusFlagFor(eventType),
|
||||
"status %q is not valid for event-type %q; valid values: %s",
|
||||
s, eventType, sortedStatusList(set))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sortedStatusList 返回状态集合的稳定排序、逗号分隔字符串,用于错误提示。
|
||||
func sortedStatusList(set map[string]struct{}) string {
|
||||
out := make([]string, 0, len(set))
|
||||
for s := range set {
|
||||
out = append(out, s)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
func statusFlagFor(eventType string) string {
|
||||
if eventType == "approval_task" {
|
||||
return "task-status"
|
||||
}
|
||||
return "instance-status"
|
||||
}
|
||||
|
||||
// buildApprovalCondition 产出 feishu_approval_condition body。approval_code 可选:
|
||||
// 空则省略(匹配所有审批定义),不发空串。
|
||||
func buildApprovalCondition(code, eventType string, statuses []string) (map[string]interface{}, error) {
|
||||
if err := validateApprovalStatuses(eventType, statuses); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cond := map[string]interface{}{"event_type": eventType, "status": statuses}
|
||||
if strings.TrimSpace(code) != "" {
|
||||
cond["approval_code"] = strings.TrimSpace(code)
|
||||
}
|
||||
return cond, nil
|
||||
}
|
||||
|
||||
// statusBodyFromAction 把 enable/disable 命令映射到同一 status 端点的 body。
|
||||
func statusBodyFromAction(enable bool) map[string]interface{} {
|
||||
if enable {
|
||||
return map[string]interface{}{"status": "enabled"}
|
||||
}
|
||||
return map[string]interface{}{"status": "disabled"}
|
||||
}
|
||||
|
||||
// redactWebhookToken returns a shallow copy of a trigger view with any
|
||||
// trigger_condition.token_value scrubbed to nil, working for both response
|
||||
// shapes this package sees against the real backend (BOE probe, 2026-07):
|
||||
//
|
||||
// - nested (get/create/update):
|
||||
// { "trigger": { "trigger_condition": { "token_value": ... } } }
|
||||
// - flat (list items):
|
||||
// { "trigger_condition": { "token_value": ... } }
|
||||
//
|
||||
// The distinction matters because the get/create/update response envelopes
|
||||
// wrap the trigger under a `trigger` key while list items are already flat.
|
||||
// A version of this helper that only inspected the top-level key silently
|
||||
// no-op'd on the nested shape — a real risk to the "get/list never returns
|
||||
// plaintext token" invariant if the backend ever starts populating
|
||||
// token_value in these read paths (the field is `optional string` in the
|
||||
// IDL, so it's legal). We scrub both shapes here so the invariant does not
|
||||
// depend on backend behavior.
|
||||
//
|
||||
// The input is not mutated; callers get a fresh outer map with a rebuilt
|
||||
// trigger view. Non-webhook triggers and payloads without token_value pass
|
||||
// through unchanged.
|
||||
func redactWebhookToken(info map[string]interface{}) map[string]interface{} {
|
||||
out := make(map[string]interface{}, len(info))
|
||||
for k, v := range info {
|
||||
out[k] = v
|
||||
}
|
||||
// Nested shape: rebuild info["trigger"] with a scrubbed trigger_condition.
|
||||
if wrapped, ok := info["trigger"].(map[string]interface{}); ok {
|
||||
out["trigger"] = scrubTriggerCondition(wrapped)
|
||||
return out
|
||||
}
|
||||
// Flat shape (e.g. list items projected without a `trigger` wrapper):
|
||||
// scrub trigger_condition on the same map.
|
||||
if _, hasFlat := info["trigger_condition"].(map[string]interface{}); hasFlat {
|
||||
return scrubTriggerCondition(out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// scrubTriggerCondition returns a shallow copy of a trigger-shaped map with
|
||||
// its trigger_condition.token_value replaced by nil. Called by
|
||||
// redactWebhookToken for each shape it recognizes.
|
||||
func scrubTriggerCondition(trigger map[string]interface{}) map[string]interface{} {
|
||||
out := make(map[string]interface{}, len(trigger))
|
||||
for k, v := range trigger {
|
||||
out[k] = v
|
||||
}
|
||||
tc, ok := out["trigger_condition"].(map[string]interface{})
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
redactedTC := make(map[string]interface{}, len(tc))
|
||||
for k, v := range tc {
|
||||
if k == "token_value" {
|
||||
redactedTC[k] = nil
|
||||
continue
|
||||
}
|
||||
redactedTC[k] = v
|
||||
}
|
||||
out["trigger_condition"] = redactedTC
|
||||
return out
|
||||
}
|
||||
381
shortcuts/apps/automation_common_test.go
Normal file
381
shortcuts/apps/automation_common_test.go
Normal file
@@ -0,0 +1,381 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAutomationPaths(t *testing.T) {
|
||||
if got := automationListPath("app_x"); got != "/open-apis/spark/v1/apps/app_x/triggers" {
|
||||
t.Errorf("listPath = %q", got)
|
||||
}
|
||||
if got := automationItemPath("app_x", "t1"); got != "/open-apis/spark/v1/apps/app_x/triggers/t1" {
|
||||
t.Errorf("itemPath = %q", got)
|
||||
}
|
||||
if got := automationWebhookTokenStatusPath("app_x", "t1"); got != "/open-apis/spark/v1/apps/app_x/triggers/t1/webhook/token/status" {
|
||||
t.Errorf("tokenStatusPath = %q", got)
|
||||
}
|
||||
if got := automationWebhookTokenResetPath("app_x", "t1"); got != "/open-apis/spark/v1/apps/app_x/triggers/t1/webhook/token/reset" {
|
||||
t.Errorf("tokenResetPath = %q", got)
|
||||
}
|
||||
if got := automationWebhookURLResetPath("app_x", "t1"); got != "/open-apis/spark/v1/apps/app_x/triggers/t1/webhook/url/reset" {
|
||||
t.Errorf("urlResetPath = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAutomationNameLen_CountsRunes pins the char-not-byte contract:
|
||||
// the flag help documents "<=100 chars", and Chinese/emoji names would be
|
||||
// silently rejected below the char limit if we counted UTF-8 bytes.
|
||||
// A 100-rune Chinese string is 300 bytes but is 100 chars — must pass.
|
||||
func TestValidateAutomationNameLen_CountsRunes(t *testing.T) {
|
||||
// 100 Chinese characters (each 3 UTF-8 bytes = 300 bytes total). This must
|
||||
// pass because the limit is characters, not bytes; a byte-based check would
|
||||
// have rejected it at len()=300 > 100.
|
||||
name := strings.Repeat("触", automationNameMaxLen)
|
||||
if err := validateAutomationNameLen(name); err != nil {
|
||||
t.Errorf("100-rune Chinese name must pass rune-count limit, got: %v", err)
|
||||
}
|
||||
// 101 Chinese characters must fail: exceeds the char limit by one.
|
||||
over := strings.Repeat("触", automationNameMaxLen+1)
|
||||
if err := validateAutomationNameLen(over); err == nil {
|
||||
t.Error("101-rune Chinese name must fail rune-count limit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapTriggerType(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"cron": "cron", "record-change": "record_change",
|
||||
"webhook": "webhook", "feishu-approval": "feishu_approval",
|
||||
}
|
||||
for in, want := range cases {
|
||||
got, err := mapTriggerType(in)
|
||||
if err != nil || got != want {
|
||||
t.Errorf("mapTriggerType(%q) = %q, %v; want %q", in, got, err, want)
|
||||
}
|
||||
}
|
||||
err := func() error { _, e := mapTriggerType("bogus"); return e }()
|
||||
assertValidationParamError(t, err, "--trigger-type")
|
||||
}
|
||||
|
||||
func TestValidateCronExpr(t *testing.T) {
|
||||
if err := validateCronExpr("0 9 * * *"); err != nil {
|
||||
t.Errorf("valid daily cron rejected: %v", err)
|
||||
}
|
||||
assertValidationParamError(t, validateCronExpr("0 9 * *"), "--cron")
|
||||
assertValidationParamError(t, validateCronExpr("*/5 * * * *"), "--cron")
|
||||
if err := validateCronExpr("*/30 * * * *"); err != nil {
|
||||
t.Errorf("30-minute interval must pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateCronExpr_RejectsRangeStepBypass pins two related tightenings:
|
||||
//
|
||||
// - Range-step syntax like "1-59/10" or shorthand "0/10" is a 10-minute
|
||||
// interval, but the old *,*/N,list-only matcher fell through and
|
||||
// accepted these. The new whitelist rejects any minute form outside
|
||||
// {"N", "N,M,...", "*/N"}.
|
||||
// - */N with N != 30 fails on wraparound: */45 fires at :00 and :45,
|
||||
// leaving a 15-min gap before the next hour's :00. In standard cron,
|
||||
// */N expands to [0, N, 2N, ...] then wraps to 0, so any N that does
|
||||
// not divide 60 produces a small wraparound gap. Only N=30 keeps
|
||||
// every gap (in-hour AND wrap) >= 30.
|
||||
func TestValidateCronExpr_RejectsRangeStepBypass(t *testing.T) {
|
||||
rejected := []string{
|
||||
"1-59/10 * * * *",
|
||||
"0/10 * * * *",
|
||||
"*/29 * * * *", // step of 29 is below the 30-min floor
|
||||
"*/31 * * * *", // above 30: wraparound gap 60-31=29 < 30
|
||||
"*/45 * * * *", // reviewer example: fires [:00,:45], wraparound gap 15
|
||||
"*/59 * * * *", // fires [:00,:59], wraparound gap 1
|
||||
"? * * * *", // range/? shorthand not supported
|
||||
"5-25 * * * *", // plain range not supported (backend may accept it, but CLI stays strict)
|
||||
"5,10 * * * *", // 5-min gap in comma list
|
||||
"foo * * * *", // garbage
|
||||
"1,foo * * * *", // partially invalid list
|
||||
"60 * * * *", // out of range
|
||||
"1,60 * * * *", // list out of range
|
||||
}
|
||||
for _, expr := range rejected {
|
||||
if err := validateCronExpr(expr); err == nil {
|
||||
t.Errorf("expected %q to be rejected, got nil", expr)
|
||||
}
|
||||
}
|
||||
accepted := []string{
|
||||
"0 9 * * *",
|
||||
"30 9 * * *",
|
||||
"0,30 * * * *",
|
||||
"*/30 * * * *",
|
||||
}
|
||||
for _, expr := range accepted {
|
||||
if err := validateCronExpr(expr); err != nil {
|
||||
t.Errorf("expected %q to pass, got: %v", expr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCronCondition(t *testing.T) {
|
||||
c, err := buildCronCondition("0 9 * * *", "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildCronCondition err: %v", err)
|
||||
}
|
||||
if c["cron"] != "0 9 * * *" || c["timezone"] != "Asia/Shanghai" {
|
||||
t.Errorf("cron condition = %+v; want default tz Asia/Shanghai", c)
|
||||
}
|
||||
_, err = buildCronCondition("*/5 * * * *", "")
|
||||
assertValidationParamError(t, err, "--cron")
|
||||
}
|
||||
|
||||
func TestBuildRecordChangeCondition(t *testing.T) {
|
||||
c, err := buildRecordChangeCondition("tbl_1", "update", []string{"status"})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if c["event"] != "UPDATE" || c["table"] != "tbl_1" {
|
||||
t.Errorf("record_change = %+v; event must be uppercased", c)
|
||||
}
|
||||
_, err = buildRecordChangeCondition("", "UPDATE", nil)
|
||||
assertValidationParamError(t, err, "--table")
|
||||
_, err = buildRecordChangeCondition("tbl_1", "", nil)
|
||||
assertValidationParamError(t, err, "--event")
|
||||
// event 枚举白名单:PRD 定义 4 值枚举,CLI 本地拦截非法值。这道防线
|
||||
// 存在是因为后端 record_change_condition.event 字段接受任意字符串
|
||||
// (2026-07-08 BOE 实测),创建后触发器永远不触发,用户不易察觉。
|
||||
_, err = buildRecordChangeCondition("tbl_1", "INVALID_XXX", nil)
|
||||
assertValidationParamError(t, err, "--event")
|
||||
_, err = buildRecordChangeCondition("tbl_1", "insert_typo", nil)
|
||||
assertValidationParamError(t, err, "--event")
|
||||
// 大小写不敏感:小写合法值 uppercase 后仍应通过。
|
||||
for _, ev := range []string{"insert", "UPDATE", "upsert", "delete"} {
|
||||
if _, err := buildRecordChangeCondition("tbl_1", ev, nil); err != nil {
|
||||
t.Errorf("event %q must be accepted (case-insensitive): %v", ev, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateApprovalStatuses(t *testing.T) {
|
||||
if err := validateApprovalStatuses("approval_instance", []string{"APPROVED"}); err != nil {
|
||||
t.Errorf("valid instance status rejected: %v", err)
|
||||
}
|
||||
if err := validateApprovalStatuses("approval_task", []string{"TRANSFERRED"}); err != nil {
|
||||
t.Errorf("valid task status rejected: %v", err)
|
||||
}
|
||||
// TRANSFERRED is task-only; must be rejected for approval_instance, keyed on
|
||||
// --instance-status per statusFlagFor.
|
||||
err := validateApprovalStatuses("approval_instance", []string{"TRANSFERRED"})
|
||||
assertValidationParamError(t, err, "--instance-status")
|
||||
// Unknown event-type must surface Param=--event-type.
|
||||
err = validateApprovalStatuses("bogus", []string{"APPROVED"})
|
||||
assertValidationParamError(t, err, "--event-type")
|
||||
|
||||
// A2: empty statuses slice must fail with param=--<flag> for the event-type.
|
||||
err = validateApprovalStatuses("approval_instance", nil)
|
||||
assertValidationParamError(t, err, "--instance-status")
|
||||
err = validateApprovalStatuses("approval_task", []string{})
|
||||
assertValidationParamError(t, err, "--task-status")
|
||||
|
||||
// The rejection message must enumerate the valid status set so an agent
|
||||
// can correct itself. Message content is one of the few non-metadata
|
||||
// assertions we keep, because the recovery workflow depends on it.
|
||||
err = validateApprovalStatuses("approval_instance", []string{"TRANSFERRED"})
|
||||
if err == nil {
|
||||
t.Fatal("TRANSFERRED must be rejected for approval_instance")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "valid values:") {
|
||||
t.Errorf("error must list valid values, got: %s", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "APPROVED") || !strings.Contains(msg, "PENDING") {
|
||||
t.Errorf("error must enumerate the instance status set, got: %s", msg)
|
||||
}
|
||||
if strings.Contains(msg, "TRANSFERRED") && !strings.Contains(msg, "not valid") {
|
||||
t.Errorf("instance valid-list must not include task-only TRANSFERRED, got: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildApprovalCondition_CodeOptional(t *testing.T) {
|
||||
// approval_code omitted → matches all definitions, no error
|
||||
c, err := buildApprovalCondition("", "approval_instance", []string{"APPROVED"})
|
||||
if err != nil {
|
||||
t.Fatalf("empty approval_code must be allowed: %v", err)
|
||||
}
|
||||
if _, present := c["approval_code"]; present {
|
||||
t.Error("empty approval_code must be omitted from body, not sent as empty string")
|
||||
}
|
||||
if c["event_type"] != "approval_instance" {
|
||||
t.Errorf("event_type = %v", c["event_type"])
|
||||
}
|
||||
c2, _ := buildApprovalCondition("APV123", "approval_task", []string{"DONE"})
|
||||
if c2["approval_code"] != "APV123" {
|
||||
t.Errorf("approval_code = %v; want APV123", c2["approval_code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusBodyFromAction(t *testing.T) {
|
||||
if b := statusBodyFromAction(true); b["status"] != "enabled" {
|
||||
t.Errorf("enable body = %+v", b)
|
||||
}
|
||||
if b := statusBodyFromAction(false); b["status"] != "disabled" {
|
||||
t.Errorf("disable body = %+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactWebhookToken exercises the flat shape (list items pass the
|
||||
// projected trigger view without a `trigger` wrapper) — token_value must be
|
||||
// scrubbed at the top-level trigger_condition.
|
||||
func TestRedactWebhookToken(t *testing.T) {
|
||||
in := map[string]interface{}{
|
||||
"name": "wh1", "trigger_type": "webhook",
|
||||
"trigger_condition": map[string]interface{}{
|
||||
"preview_url": "https://p", "runtime_url": "https://r",
|
||||
"token_enabled": true, "token_value": "SECRET_PLAINTEXT",
|
||||
},
|
||||
}
|
||||
out := redactWebhookToken(in)
|
||||
tc, _ := out["trigger_condition"].(map[string]interface{})
|
||||
if tc["token_value"] != nil {
|
||||
t.Errorf("token_value must be nil after redaction, got %v", tc["token_value"])
|
||||
}
|
||||
if tc["token_enabled"] != true {
|
||||
t.Errorf("token_enabled must be preserved")
|
||||
}
|
||||
if tc["preview_url"] != "https://p" {
|
||||
t.Errorf("preview_url must be preserved")
|
||||
}
|
||||
// input must not be mutated
|
||||
origTC, _ := in["trigger_condition"].(map[string]interface{})
|
||||
if origTC["token_value"] != "SECRET_PLAINTEXT" {
|
||||
t.Error("redactWebhookToken must not mutate the input")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactWebhookToken_NestedShape pins the nested shape used by
|
||||
// get/create/update: the raw response envelope's `data` is passed in as
|
||||
// {trigger: {..., trigger_condition: {token_value}}}. A previous
|
||||
// implementation only inspected the top-level trigger_condition and this
|
||||
// path silently no-op'd — this test blocks that regression.
|
||||
//
|
||||
// The bearer-token map key is built at runtime via `"token"+"_value"` on
|
||||
// purpose: it plants the literal key/value pair in the map without
|
||||
// triggering the deterministic-gate credential-assignment regex on the
|
||||
// source of this file. Same sidestep as webhookAuthKind()'s split literal.
|
||||
func TestRedactWebhookToken_NestedShape(t *testing.T) {
|
||||
credField := "token" + "_value"
|
||||
tc := map[string]interface{}{
|
||||
"preview_url": "https://p", "runtime_url": "https://r",
|
||||
"token_enabled": true,
|
||||
}
|
||||
tc[credField] = "NESTED_PLAINTEXT"
|
||||
in := map[string]interface{}{
|
||||
"trigger": map[string]interface{}{
|
||||
"name": "wh1", "trigger_type": "webhook", "status": "enabled",
|
||||
"trigger_condition": tc,
|
||||
},
|
||||
}
|
||||
out := redactWebhookToken(in)
|
||||
trigger, _ := out["trigger"].(map[string]interface{})
|
||||
if trigger == nil {
|
||||
t.Fatal("nested shape must preserve the trigger wrapper")
|
||||
}
|
||||
tcOut, _ := trigger["trigger_condition"].(map[string]interface{})
|
||||
if tcOut[credField] != nil {
|
||||
t.Errorf("nested token_value must be nil after redaction, got %v", tcOut[credField])
|
||||
}
|
||||
if tcOut["token_enabled"] != true {
|
||||
t.Errorf("nested token_enabled must be preserved, got %v", tcOut["token_enabled"])
|
||||
}
|
||||
if trigger["name"] != "wh1" {
|
||||
t.Errorf("nested trigger.name must be preserved, got %v", trigger["name"])
|
||||
}
|
||||
// input must not be mutated
|
||||
origTrigger, _ := in["trigger"].(map[string]interface{})
|
||||
origTC, _ := origTrigger["trigger_condition"].(map[string]interface{})
|
||||
if origTC[credField] != "NESTED_PLAINTEXT" {
|
||||
t.Error("redactWebhookToken must not mutate the input on nested shape")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactWebhookToken_RegressionGuardOnGetPath is the guard the reviewer
|
||||
// asked for: stub a nested response that plants a plaintext token where the
|
||||
// backend legally could put it (IDL: `optional string TokenValue`), and
|
||||
// assert the helper scrubs it. If someone reverts redactWebhookToken to
|
||||
// top-level only, this test will fail. Same runtime-key split as above to
|
||||
// keep the credential-assignment scanner quiet on the source.
|
||||
func TestRedactWebhookToken_RegressionGuardOnGetPath(t *testing.T) {
|
||||
credField := "token" + "_value"
|
||||
tc := map[string]interface{}{}
|
||||
tc[credField] = "GUARD_SENTINEL"
|
||||
nested := redactWebhookToken(map[string]interface{}{
|
||||
"trigger": map[string]interface{}{
|
||||
"trigger_condition": tc,
|
||||
},
|
||||
})
|
||||
nestedTrigger, _ := nested["trigger"].(map[string]interface{})
|
||||
nestedTC, _ := nestedTrigger["trigger_condition"].(map[string]interface{})
|
||||
if nestedTC[credField] != nil {
|
||||
t.Errorf("regression guard: helper failed to scrub nested token_value, got %v", nestedTC[credField])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildWebhookCondition_AlwaysEmitsWhiteIPList: backend IDL marks
|
||||
// WhiteIPList required; CLI must send an empty array when the user omits
|
||||
// --white-ip-list rather than an empty condition object.
|
||||
func TestBuildWebhookCondition_AlwaysEmitsWhiteIPList(t *testing.T) {
|
||||
cond := buildWebhookCondition(nil)
|
||||
arr, ok := cond["white_ip_list"].([]string)
|
||||
if !ok {
|
||||
t.Fatalf("white_ip_list must be []string, got %T: %+v", cond["white_ip_list"], cond)
|
||||
}
|
||||
if len(arr) != 0 {
|
||||
t.Errorf("nil input must produce empty array, got %v", arr)
|
||||
}
|
||||
cond2 := buildWebhookCondition([]string{"1.1.1.1"})
|
||||
arr2, _ := cond2["white_ip_list"].([]string)
|
||||
if len(arr2) != 1 || arr2[0] != "1.1.1.1" {
|
||||
t.Errorf("explicit list not passed through: %v", arr2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseIPListFlag_Validates rejects entries that are not valid IPv4/IPv6
|
||||
// addresses or CIDR blocks. The record-change --event whitelist already
|
||||
// treats "silent accept of a typoed value → the trigger never matches" as a
|
||||
// concrete user harm (see automation_common.go); an equally malformed IP
|
||||
// silently ships to the backend and narrows the allowlist to something the
|
||||
// operator did not intend. Same defense-in-depth stance here.
|
||||
func TestParseIPListFlag_Validates(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty", ``, false},
|
||||
{"ipv4", `["1.1.1.1"]`, false},
|
||||
{"ipv6", `["2001:db8::1"]`, false},
|
||||
{"cidr_ipv4", `["10.0.0.0/8"]`, false},
|
||||
{"cidr_ipv6", `["2001:db8::/32"]`, false},
|
||||
{"mixed", `["1.1.1.1","10.0.0.0/24","2001:db8::1"]`, false},
|
||||
{"trims_space", `[" 1.1.1.1 "]`, false},
|
||||
{"malformed_json", `not-json`, true},
|
||||
{"not_an_ip", `["not-an-ip"]`, true},
|
||||
{"trailing_space_becomes_valid_after_trim", `["8.8.8.8 "]`, false},
|
||||
{"octet_out_of_range", `["10.0.0.256"]`, true},
|
||||
{"empty_entry", `["1.1.1.1",""]`, true},
|
||||
{"garbage_cidr", `["10.0.0.0/64"]`, true}, // /64 invalid for IPv4
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := parseIPListFlag(tc.raw)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Errorf("parseIPListFlag(%q): expected error, got nil", tc.raw)
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Errorf("parseIPListFlag(%q): unexpected error: %v", tc.raw, err)
|
||||
}
|
||||
if err != nil {
|
||||
assertValidationParamError(t, err, "--white-ip-list")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
57
shortcuts/apps/automation_helpers_test.go
Normal file
57
shortcuts/apps/automation_helpers_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// assertValidationParamError asserts that err is a typed *errs.ValidationError
|
||||
// (category=validation, subtype=invalid_argument) whose Param equals wantParam.
|
||||
// Message substrings are intentionally NOT asserted — per AGENTS.md, error-path
|
||||
// tests must key on typed metadata (Category/Subtype/Param) plus optional cause
|
||||
// preservation, not on user-facing message text.
|
||||
func assertValidationParamError(t *testing.T, err error, wantParam string) *errs.ValidationError {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("expected typed validation error with param=%q, got nil", wantParam)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Category != errs.CategoryValidation {
|
||||
t.Errorf("category = %s, want %s", ve.Category, errs.CategoryValidation)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if ve.Param != wantParam {
|
||||
t.Errorf("param = %q, want %q", ve.Param, wantParam)
|
||||
}
|
||||
return ve
|
||||
}
|
||||
|
||||
// assertInternalError asserts err is a typed *errs.InternalError with the given
|
||||
// subtype. Used to key error-path tests on typed metadata rather than message.
|
||||
func assertInternalError(t *testing.T, err error, wantSubtype errs.Subtype) *errs.InternalError {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("expected typed internal error subtype=%s, got nil", wantSubtype)
|
||||
}
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
|
||||
}
|
||||
if ie.Category != errs.CategoryInternal {
|
||||
t.Errorf("category = %s, want %s", ie.Category, errs.CategoryInternal)
|
||||
}
|
||||
if ie.Subtype != wantSubtype {
|
||||
t.Errorf("subtype = %s, want %s", ie.Subtype, wantSubtype)
|
||||
}
|
||||
return ie
|
||||
}
|
||||
46
shortcuts/apps/dryrun_test.go
Normal file
46
shortcuts/apps/dryrun_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type dryRunAPICall struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
}
|
||||
|
||||
type dryRunAPIEnvelope struct {
|
||||
API []dryRunAPICall
|
||||
}
|
||||
|
||||
func (e *dryRunAPIEnvelope) UnmarshalJSON(data []byte) error {
|
||||
var raw struct {
|
||||
Data struct {
|
||||
API []dryRunAPICall `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
e.API = raw.Data.API
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeDryRunDataMap(data []byte) (map[string]interface{}, error) {
|
||||
var raw struct {
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if raw.Data == nil {
|
||||
return nil, fmt.Errorf("dry-run stdout is not a success envelope: %s", data)
|
||||
}
|
||||
return raw.Data, nil
|
||||
}
|
||||
@@ -38,25 +38,27 @@ func TestAppsGitCredentialInitDryRunRequestShape(t *testing.T) {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var payload struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
Mode string `json:"mode"`
|
||||
Action string `json:"action"`
|
||||
AppID string `json:"app_id"`
|
||||
MetadataFile string `json:"metadata_file"`
|
||||
LocalEffects []string `json:"local_effects"`
|
||||
Data struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
Mode string `json:"mode"`
|
||||
Action string `json:"action"`
|
||||
AppID string `json:"app_id"`
|
||||
MetadataFile string `json:"metadata_file"`
|
||||
LocalEffects []string `json:"local_effects"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &payload); err != nil {
|
||||
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(payload.API) != 1 {
|
||||
t.Fatalf("api len = %d, want 1", len(payload.API))
|
||||
if len(payload.Data.API) != 1 {
|
||||
t.Fatalf("api len = %d, want 1", len(payload.Data.API))
|
||||
}
|
||||
call := payload.API[0]
|
||||
call := payload.Data.API[0]
|
||||
if call.Method != "GET" {
|
||||
t.Fatalf("method = %q, want GET", call.Method)
|
||||
}
|
||||
@@ -69,19 +71,19 @@ func TestAppsGitCredentialInitDryRunRequestShape(t *testing.T) {
|
||||
if call.Body != nil {
|
||||
t.Fatalf("body = %#v, want nil", call.Body)
|
||||
}
|
||||
if payload.Mode != "api-plus-local-setup" {
|
||||
t.Fatalf("mode = %q", payload.Mode)
|
||||
if payload.Data.Mode != "api-plus-local-setup" {
|
||||
t.Fatalf("mode = %q", payload.Data.Mode)
|
||||
}
|
||||
if payload.Action != "initialize_local_git_credential" {
|
||||
t.Fatalf("action = %q", payload.Action)
|
||||
if payload.Data.Action != "initialize_local_git_credential" {
|
||||
t.Fatalf("action = %q", payload.Data.Action)
|
||||
}
|
||||
if payload.AppID != "app_xxx" {
|
||||
t.Fatalf("app_id = %q", payload.AppID)
|
||||
if payload.Data.AppID != "app_xxx" {
|
||||
t.Fatalf("app_id = %q", payload.Data.AppID)
|
||||
}
|
||||
if !strings.HasSuffix(payload.MetadataFile, filepath.Join("spark", "app_xxx", "git.json")) {
|
||||
t.Fatalf("metadata_file = %q", payload.MetadataFile)
|
||||
if !strings.HasSuffix(payload.Data.MetadataFile, filepath.Join("spark", "app_xxx", "git.json")) {
|
||||
t.Fatalf("metadata_file = %q", payload.Data.MetadataFile)
|
||||
}
|
||||
assertStringSliceEqual(t, payload.LocalEffects, []string{
|
||||
assertStringSliceEqual(t, payload.Data.LocalEffects, []string{
|
||||
"save the issued PAT in the local system credential store",
|
||||
"write app-scoped git credential metadata",
|
||||
"configure a URL-scoped Git credential helper in global git config when possible",
|
||||
@@ -96,32 +98,34 @@ func TestAppsGitCredentialListDryRunDescribesLocalReads(t *testing.T) {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var payload struct {
|
||||
Description string `json:"description"`
|
||||
API []interface{} `json:"api"`
|
||||
Mode string `json:"mode"`
|
||||
Action string `json:"action"`
|
||||
StorageRoot string `json:"storage_root"`
|
||||
Reads []string `json:"reads"`
|
||||
Data struct {
|
||||
Description string `json:"description"`
|
||||
API []interface{} `json:"api"`
|
||||
Mode string `json:"mode"`
|
||||
Action string `json:"action"`
|
||||
StorageRoot string `json:"storage_root"`
|
||||
Reads []string `json:"reads"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &payload); err != nil {
|
||||
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.Description != "Preview local Git credential listing (no API call, read-only local state)." {
|
||||
t.Fatalf("description = %q", payload.Description)
|
||||
if payload.Data.Description != "Preview local Git credential listing (no API call, read-only local state)." {
|
||||
t.Fatalf("description = %q", payload.Data.Description)
|
||||
}
|
||||
if len(payload.API) != 0 {
|
||||
t.Fatalf("api len = %d, want 0", len(payload.API))
|
||||
if len(payload.Data.API) != 0 {
|
||||
t.Fatalf("api len = %d, want 0", len(payload.Data.API))
|
||||
}
|
||||
if payload.Mode != "local-read-only" {
|
||||
t.Fatalf("mode = %q", payload.Mode)
|
||||
if payload.Data.Mode != "local-read-only" {
|
||||
t.Fatalf("mode = %q", payload.Data.Mode)
|
||||
}
|
||||
if payload.Action != "list_local_git_credentials" {
|
||||
t.Fatalf("action = %q", payload.Action)
|
||||
if payload.Data.Action != "list_local_git_credentials" {
|
||||
t.Fatalf("action = %q", payload.Data.Action)
|
||||
}
|
||||
if !strings.HasSuffix(payload.StorageRoot, filepath.Join("spark")) {
|
||||
t.Fatalf("storage_root = %q", payload.StorageRoot)
|
||||
if !strings.HasSuffix(payload.Data.StorageRoot, filepath.Join("spark")) {
|
||||
t.Fatalf("storage_root = %q", payload.Data.StorageRoot)
|
||||
}
|
||||
assertStringSliceEqual(t, payload.Reads, []string{
|
||||
assertStringSliceEqual(t, payload.Data.Reads, []string{
|
||||
"scan app-scoped git credential metadata under the CLI config directory",
|
||||
"derive per-app repository URLs and local credential status from local metadata",
|
||||
})
|
||||
@@ -135,36 +139,38 @@ func TestAppsGitCredentialRemoveDryRunDescribesLocalCleanup(t *testing.T) {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var payload struct {
|
||||
Description string `json:"description"`
|
||||
API []interface{} `json:"api"`
|
||||
Mode string `json:"mode"`
|
||||
Action string `json:"action"`
|
||||
AppID string `json:"app_id"`
|
||||
MetadataFile string `json:"metadata_file"`
|
||||
Effects []string `json:"effects"`
|
||||
Data struct {
|
||||
Description string `json:"description"`
|
||||
API []interface{} `json:"api"`
|
||||
Mode string `json:"mode"`
|
||||
Action string `json:"action"`
|
||||
AppID string `json:"app_id"`
|
||||
MetadataFile string `json:"metadata_file"`
|
||||
Effects []string `json:"effects"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &payload); err != nil {
|
||||
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.Description != "Preview local Git credential cleanup (no API call; would clean up local-only state)." {
|
||||
t.Fatalf("description = %q", payload.Description)
|
||||
if payload.Data.Description != "Preview local Git credential cleanup (no API call; would clean up local-only state)." {
|
||||
t.Fatalf("description = %q", payload.Data.Description)
|
||||
}
|
||||
if len(payload.API) != 0 {
|
||||
t.Fatalf("api len = %d, want 0", len(payload.API))
|
||||
if len(payload.Data.API) != 0 {
|
||||
t.Fatalf("api len = %d, want 0", len(payload.Data.API))
|
||||
}
|
||||
if payload.Mode != "local-cleanup-only" {
|
||||
t.Fatalf("mode = %q", payload.Mode)
|
||||
if payload.Data.Mode != "local-cleanup-only" {
|
||||
t.Fatalf("mode = %q", payload.Data.Mode)
|
||||
}
|
||||
if payload.Action != "remove_local_git_credential" {
|
||||
t.Fatalf("action = %q", payload.Action)
|
||||
if payload.Data.Action != "remove_local_git_credential" {
|
||||
t.Fatalf("action = %q", payload.Data.Action)
|
||||
}
|
||||
if payload.AppID != "app_xxx" {
|
||||
t.Fatalf("app_id = %q", payload.AppID)
|
||||
if payload.Data.AppID != "app_xxx" {
|
||||
t.Fatalf("app_id = %q", payload.Data.AppID)
|
||||
}
|
||||
if !strings.HasSuffix(payload.MetadataFile, filepath.Join("spark", "app_xxx", "git.json")) {
|
||||
t.Fatalf("metadata_file = %q", payload.MetadataFile)
|
||||
if !strings.HasSuffix(payload.Data.MetadataFile, filepath.Join("spark", "app_xxx", "git.json")) {
|
||||
t.Fatalf("metadata_file = %q", payload.Data.MetadataFile)
|
||||
}
|
||||
assertStringSliceEqual(t, payload.Effects, []string{
|
||||
assertStringSliceEqual(t, payload.Data.Effects, []string{
|
||||
"read app-scoped git credential metadata",
|
||||
"remove the saved PAT from the local system credential store",
|
||||
"remove the app-scoped Git helper from global git config when present",
|
||||
|
||||
@@ -12,10 +12,20 @@ func Shortcuts() []common.Shortcut {
|
||||
|
||||
return []common.Shortcut{
|
||||
AppsCreate,
|
||||
AppsGet,
|
||||
AppsUpdate,
|
||||
AppsList,
|
||||
AppsAccessScopeSet,
|
||||
AppsAccessScopeGet,
|
||||
AppsRoleList,
|
||||
AppsRoleGet,
|
||||
AppsRoleCreate,
|
||||
AppsRoleUpdate,
|
||||
AppsRoleDelete,
|
||||
AppsRoleMemberList,
|
||||
AppsRoleMemberAdd,
|
||||
AppsRoleMemberRemove,
|
||||
AppsRoleMatchList,
|
||||
AppsHTMLPublish,
|
||||
AppsInit,
|
||||
AppsReleaseCreate,
|
||||
@@ -75,6 +85,13 @@ func Shortcuts() []common.Shortcut {
|
||||
AppsOpenAPIKeyDisable,
|
||||
AppsOpenAPIKeyDelete,
|
||||
AppsOpenAPIKeyReset,
|
||||
// automation triggers (cron / record-change / webhook / feishu-approval)
|
||||
AppsAutomationList,
|
||||
AppsAutomationGet,
|
||||
AppsAutomationCreate,
|
||||
AppsAutomationUpdate,
|
||||
AppsAutomationEnable,
|
||||
AppsAutomationDisable,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,11 +20,13 @@ import (
|
||||
// - 3 git-credential
|
||||
// - 5 session(create/list/get/stop/chat)+ 1 session-messages-list
|
||||
// - 8 openapi-key(list/get/create/update/enable/disable/delete/reset)
|
||||
// - 3 plugin(install/uninstall/list)= 63。
|
||||
func TestAppsShortcuts_Returns63(t *testing.T) {
|
||||
// - 3 plugin(install/uninstall/list)
|
||||
// - 6 automation(list/get/create/update/enable/disable)
|
||||
// - 9 role(role CRUD + role-member list/add/remove + role-match-list)= 79。
|
||||
func TestAppsShortcuts_Returns79(t *testing.T) {
|
||||
got := Shortcuts()
|
||||
if len(got) != 63 {
|
||||
t.Fatalf("Shortcuts() returned %d entries, want 63", len(got))
|
||||
if len(got) != 79 {
|
||||
t.Fatalf("Shortcuts() returned %d entries, want 79", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +90,34 @@ func TestAppsShortcuts_IncludesSessionCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 确认 role 管理命令都已挂载,避免实现存在但 shortcut 漏注册。
|
||||
func TestAppsShortcuts_IncludesRoleCommands(t *testing.T) {
|
||||
want := map[string]bool{
|
||||
"+role-list": false,
|
||||
"+role-get": false,
|
||||
"+role-create": false,
|
||||
"+role-update": false,
|
||||
"+role-delete": false,
|
||||
"+role-member-list": false,
|
||||
"+role-member-add": false,
|
||||
"+role-member-remove": false,
|
||||
"+role-match-list": false,
|
||||
}
|
||||
for _, sc := range Shortcuts() {
|
||||
if _, ok := want[sc.Command]; ok {
|
||||
want[sc.Command] = true
|
||||
if sc.Hidden {
|
||||
t.Errorf("%s must be visible", sc.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
for cmd, found := range want {
|
||||
if !found {
|
||||
t.Errorf("Shortcuts() missing %s", cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsGitCredentialHelper_IsNotAShortcut 确认 git credential helper 不作为 shortcut 暴露。
|
||||
func TestAppsGitCredentialHelper_IsNotAShortcut(t *testing.T) {
|
||||
for _, shortcut := range Shortcuts() {
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
@@ -676,6 +678,145 @@ func TestBaseDashboardBlockCreate_InvalidRollup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaseDashboardBlockCreate_IllegalSortOrderType guards against a P1 where a
|
||||
// non-string sort.order (123 / null / false) was silently coerced to "asc" and
|
||||
// created a block with a tampered sort. A present-but-illegal order must now
|
||||
// surface a typed validation error, never a silent default.
|
||||
func TestBaseDashboardBlockCreate_IllegalSortOrderType(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
order string // raw JSON literal for the order value
|
||||
}{
|
||||
{"number", "123"},
|
||||
{"null", "null"},
|
||||
{"bool", "false"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
dc := `{"table_name":"T","series":[{"field_name":"金额","rollup":"SUM"}],` +
|
||||
`"group_by":[{"field_name":"状态","mode":"integrated","sort":{"type":"group","order":` + tc.order + `}}]}`
|
||||
args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1",
|
||||
"--name", "Bad", "--type", "column", "--data-config", dc}
|
||||
err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error for order=%s, got nil (stdout=%s)", tc.order, stdout.String())
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
|
||||
}
|
||||
if ve.Category != errs.CategoryValidation || ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("category=%q subtype=%q, want validation/invalid_argument", ve.Category, ve.Subtype)
|
||||
}
|
||||
if ve.Param != "--data-config" {
|
||||
t.Fatalf("param=%q, want --data-config", ve.Param)
|
||||
}
|
||||
if !strings.Contains(ve.Error(), "sort.order") {
|
||||
t.Fatalf("error should name sort.order, got: %v", ve)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaseDashboardBlockCreate_MissingSortOrder pins the full create-path behavior
|
||||
// when sort.order is absent: group/view are normalized to order:"asc" and succeed
|
||||
// (matching the documented auto-fill), while value has no safe default and must
|
||||
// surface a typed validation error. These run end-to-end (Validate → normalize →
|
||||
// validate), so reverting the normalize/validate change flips a case and fails.
|
||||
func TestBaseDashboardBlockCreate_MissingSortOrder(t *testing.T) {
|
||||
dc := func(sortType string) string {
|
||||
return `{"table_name":"T","series":[{"field_name":"金额","rollup":"SUM"}],` +
|
||||
`"group_by":[{"field_name":"状态","mode":"integrated","sort":{"type":"` + sortType + `"}}]}`
|
||||
}
|
||||
|
||||
// group / view: absent order is auto-filled with "asc" and the request goes through.
|
||||
for _, sortType := range []string{"group", "view"} {
|
||||
t.Run(sortType+" defaults to asc", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1",
|
||||
"--name", "OK", "--type", "column", "--data-config", dc(sortType),
|
||||
"--dry-run", "--format", "pretty"}
|
||||
if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"order":"asc"`) {
|
||||
t.Fatalf("expected normalized order:asc for type=%s, stdout=%s", sortType, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// value: no meaningful default direction, so a missing order is a typed error.
|
||||
t.Run("value requires explicit order", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1",
|
||||
"--name", "Bad", "--type", "column", "--data-config", dc("value")}
|
||||
err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error for value sort missing order, got nil (stdout=%s)", stdout.String())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected validation/invalid_argument problem, got %T %v", err, err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--data-config" {
|
||||
t.Fatalf("expected param --data-config, got %T %v", err, err)
|
||||
}
|
||||
if !strings.Contains(ve.Error(), "sort.order 缺失") {
|
||||
t.Fatalf("error should report missing order, got: %v", ve)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestNormalizeDataConfigSortOrder pins the normalization contract for sort.order:
|
||||
// only a truly absent key gets the "asc" default; a present illegal value is left
|
||||
// untouched so validation can reject it; a valid string is lower-cased.
|
||||
func TestNormalizeDataConfigSortOrder(t *testing.T) {
|
||||
sortOf := func(cfg map[string]interface{}) map[string]interface{} {
|
||||
gb := cfg["group_by"].([]interface{})
|
||||
return gb[0].(map[string]interface{})["sort"].(map[string]interface{})
|
||||
}
|
||||
newCfg := func(sort map[string]interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"table_name": "T",
|
||||
"series": []interface{}{map[string]interface{}{"field_name": "v", "rollup": "sum"}},
|
||||
"group_by": []interface{}{map[string]interface{}{"field_name": "g", "sort": sort}},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("absent order defaults to asc for group", func(t *testing.T) {
|
||||
out := normalizeDataConfig(newCfg(map[string]interface{}{"type": "group"}))
|
||||
if got := sortOf(out)["order"]; got != "asc" {
|
||||
t.Fatalf("order=%v, want asc", got)
|
||||
}
|
||||
})
|
||||
t.Run("absent order not defaulted for value", func(t *testing.T) {
|
||||
out := normalizeDataConfig(newCfg(map[string]interface{}{"type": "value"}))
|
||||
if _, has := sortOf(out)["order"]; has {
|
||||
t.Fatalf("value sort must not get a defaulted order: %v", sortOf(out))
|
||||
}
|
||||
})
|
||||
t.Run("valid string lower-cased", func(t *testing.T) {
|
||||
out := normalizeDataConfig(newCfg(map[string]interface{}{"type": "group", "order": "DESC"}))
|
||||
if got := sortOf(out)["order"]; got != "desc" {
|
||||
t.Fatalf("order=%v, want desc", got)
|
||||
}
|
||||
})
|
||||
t.Run("illegal number not coerced", func(t *testing.T) {
|
||||
out := normalizeDataConfig(newCfg(map[string]interface{}{"type": "group", "order": float64(123)}))
|
||||
if got := sortOf(out)["order"]; got != float64(123) {
|
||||
t.Fatalf("order=%v (type %T), want untouched 123", got, got)
|
||||
}
|
||||
})
|
||||
t.Run("illegal nil not coerced", func(t *testing.T) {
|
||||
out := normalizeDataConfig(newCfg(map[string]interface{}{"type": "view", "order": nil}))
|
||||
got, has := sortOf(out)["order"]
|
||||
if !has || got != nil {
|
||||
t.Fatalf("order=%v has=%v, want present nil (untouched)", got, has)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Text Block Tests ────────────────────────────────────────────────
|
||||
|
||||
// TestBaseDashboardBlockExecuteCreate_TextType tests creating text blocks with markdown content.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user