Compare commits

..

21 Commits

Author SHA1 Message Date
leave330
beee72f844 docs: defer standalone lark-application skill to a later iteration 2026-07-08 19:37:07 +08:00
leave330
78acceb910 fix: preserve cause chain when rewrapping name-collision error 2026-07-08 17:53:18 +08:00
leave330
cb8d09d802 test: use conventional placeholder credentials in app test config 2026-07-08 17:27:22 +08:00
leave330
42dc50f5fb test: build delete stub URL from shared base path constant 2026-07-08 17:16:02 +08:00
leave330
59ccffb09a docs: refine lark-application skill guide 2026-07-08 17:10:01 +08:00
leave330
d4f62490d0 fix: limit recommended tier to slash-command read scope 2026-07-08 16:10:18 +08:00
leave330
d8327726b0 feat: expose application domain in interactive auth login 2026-07-08 16:00:16 +08:00
leave330
3a297df5b0 docs: note interactive login picker does not yet list application domain 2026-07-08 15:38:05 +08:00
leave330
dce04f792a fix: declare shared scopes on application shortcuts for user preflight 2026-07-08 15:09:52 +08:00
leave330
fd00d43406 fix: preserve high-risk note in delete dry-run and document force i18n caveat 2026-07-08 13:28:36 +08:00
leave330
f2f49a68f4 test: add application slash command dry-run E2E coverage
Pin the dry-run request shapes for +slash-command-list/create/update/delete
(GET/POST/PATCH/DELETE paths, top-level icon key sibling to description,
description.i18n) and confirm the high-risk-write delete path exits 10 with
a confirmation_required envelope on stderr when --yes is omitted.
2026-07-08 12:38:59 +08:00
leave330
42c4b7d155 fix: classify slash command not-found as API error and preserve conflict code
- commandNotFoundError now returns errs.NewAPIError(SubtypeNotFound, ...)
  instead of a validation error: a resolution miss against the live list
  means the resource doesn't exist, not that the caller's argument shape
  was invalid.
- The no-force name-collision rewrap in +slash-command-create now carries
  over the original Code/LogID from the upstream conflict response instead
  of dropping them.
- +slash-command-delete now prints an extra stderr note that recreating the
  same command name yields a NEW command_id.
- Add a test guarding that --force only converts a name-collision response
  into an update; any other POST failure (e.g. invalid icon_key) must
  surface unchanged with no PATCH attempted.
2026-07-08 12:38:53 +08:00
leave330
96c8e638a1 docs: fix slash command trigger event reference in lark-application skill 2026-07-08 12:20:01 +08:00
leave330
ba8368504d docs: add lark-application skill for slash command management 2026-07-08 11:56:15 +08:00
leave330
c3adfc1789 feat: add slash-command-delete shortcut with high-risk confirmation 2026-07-08 11:49:00 +08:00
leave330
a290aa628f feat: add slash-command-update shortcut with by-name addressing 2026-07-08 11:43:36 +08:00
leave330
edeccaa419 feat: add slash-command-create shortcut with --force idempotent re-run 2026-07-08 11:38:39 +08:00
leave330
84962a1927 feat: add slash command name-to-id resolution helper 2026-07-08 11:29:38 +08:00
leave330
a72a562c7d feat: add application domain with slash-command-list shortcut 2026-07-08 11:25:20 +08:00
leave330
deb866f981 feat: add application domain description and slash command helpers 2026-07-08 11:17:15 +08:00
Yuxuan Zhao
6f95c5eb22 e2e: harden CLI E2E retry, cleanup, and domain selection (#1709) 2026-07-07 19:41:11 +08:00
36 changed files with 2491 additions and 123 deletions

View File

@@ -263,13 +263,19 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Resolve CLI E2E domains
id: e2e_domains
run: node scripts/e2e_domains.js
- name: Build lark-cli
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: make build
- name: Run dry-run E2E tests
env:
@@ -277,7 +283,28 @@ jobs:
LARKSUITE_CLI_APP_ID: dry-run
LARKSUITE_CLI_APP_SECRET: dry-run
LARKSUITE_CLI_BRAND: feishu
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
E2E_DRY_ROOT_PACKAGE: ${{ steps.e2e_domains.outputs.dry_root_package }}
E2E_DRY_PACKAGES: ${{ steps.e2e_domains.outputs.dry_packages }}
run: |
if [ "$E2E_MODE" = "skip" ]; then
echo "No dry-run CLI E2E needed: $E2E_REASON"
exit 0
fi
if [ -z "$E2E_DRY_ROOT_PACKAGE" ] && [ -z "$E2E_DRY_PACKAGES" ]; then
echo "::error::No dry-run CLI E2E packages resolved for mode $E2E_MODE"
exit 1
fi
echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"
if [ -n "$E2E_DRY_ROOT_PACKAGE" ]; then
echo "Dry-run CLI E2E root package: $E2E_DRY_ROOT_PACKAGE"
go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"
fi
if [ -n "$E2E_DRY_PACKAGES" ]; then
echo "Dry-run CLI E2E packages: $E2E_DRY_PACKAGES"
go test -v -count=1 -timeout=5m $E2E_DRY_PACKAGES -run 'DryRun|Regression'
fi
e2e-live:
needs: [unit-test, lint, script-test, deterministic-gate]
@@ -292,15 +319,22 @@ jobs:
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Resolve CLI E2E domains
id: e2e_domains
run: node scripts/e2e_domains.js
- name: Build lark-cli
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: make build
- name: Configure bot credentials
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: |
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
@@ -310,16 +344,24 @@ jobs:
- name: Run CLI E2E tests
env:
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
run: |
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
if [ "$E2E_MODE" = "skip" ]; then
echo "No live CLI E2E needed: $E2E_REASON"
exit 0
fi
packages="$E2E_LIVE_PACKAGES"
if [ -z "$packages" ]; then
echo "No CLI E2E packages to test after exclusions."
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
exit 1
fi
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"
echo "Live CLI E2E packages: $packages"
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
- name: Publish CLI E2E test report
if: ${{ !cancelled() }}
if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
with:
name: CLI E2E Tests

View File

@@ -51,7 +51,7 @@ script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
$(NODE) --test scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
$(NODE) --test scripts/e2e_domains.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta

View File

@@ -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"}
}

View File

@@ -14,11 +14,13 @@ import (
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
"github.com/larksuite/cli/cmd/service"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/update"
@@ -103,6 +105,11 @@ func parseTypedEnvelope(t *testing.T, stderr *bytes.Buffer) typedErrorEnvelope {
}
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
t.Helper()
return buildStrictModeIntegrationRootCmdWithCatalog(t, f, nil)
}
func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Factory, catalog *apicatalog.Catalog) *cobra.Command {
t.Helper()
rootCmd := &cobra.Command{Use: "lark-cli"}
rootCmd.SilenceErrors = true
@@ -113,7 +120,11 @@ func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.
}
rootCmd.AddCommand(auth.NewCmdAuth(f))
rootCmd.AddCommand(api.NewCmdApi(f, nil))
service.RegisterServiceCommands(rootCmd, f)
if catalog != nil {
service.RegisterServiceCommandsFromCatalog(context.Background(), rootCmd, f, *catalog)
} else {
service.RegisterServiceCommands(rootCmd, f)
}
shortcuts.RegisterShortcuts(rootCmd, f)
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
pruneForStrictMode(rootCmd, mode)
@@ -121,6 +132,29 @@ func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.
return rootCmd
}
func strictModeFixtureCatalog() apicatalog.Catalog {
return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{
{
Name: "fixture",
ServicePath: "/open-apis/fixture/v1",
Resources: map[string]meta.Resource{
"things": {
Methods: map[string]meta.Method{
"create": {
Path: "things",
HTTPMethod: "POST",
AccessTokens: []meta.Token{meta.TokenTenant},
RequestBody: map[string]meta.Field{
"name": {Type: "string"},
},
},
},
},
},
},
})
}
func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
t.Setenv(envvars.CliAppID, "")
@@ -355,10 +389,11 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
catalog := strictModeFixtureCatalog()
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
code := executeRootIntegration(t, f, rootCmd, []string{
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--dry-run",
})
if code != output.ExitValidation {

View File

@@ -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))
}
}

View File

@@ -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"

View File

@@ -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 of the current bound app (NOT Miaoda low-code apps)" },
"zh": { "title": "应用管理", "description": "开放平台应用自管理:当前绑定应用的斜杠指令管理(非妙搭低代码应用)" }
},
"apps": {
"en": { "title": "Apps", "description": "Develop, deploy HTML, web pages and applications" },
"zh": { "title": "应用", "description": "开发、部署 HTML、Web 页面和应用" }

View File

@@ -215,6 +215,73 @@ if ! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$section"; then
exit 1
fi
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$dry_run_section" ||
! grep -Fq "id: e2e_domains" <<<"$dry_run_section" ||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$dry_run_section"; then
echo "e2e-dry-run should resolve changed-file CLI E2E domains before running tests"
exit 1
fi
if ! grep -Fq "steps.e2e_domains.outputs.dry_packages" <<<"$dry_run_section"; then
echo "e2e-dry-run should use resolved dry_packages instead of always running the full suite"
exit 1
fi
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$dry_run_section" ||
! grep -Fq 'echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$dry_run_section"; then
echo "e2e-dry-run should pass dynamic domain output through env before shell use"
exit 1
fi
if ! grep -Fq "E2E_DRY_ROOT_PACKAGE: \${{ steps.e2e_domains.outputs.dry_root_package }}" <<<"$dry_run_section" ||
! grep -Fq 'go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"' <<<"$dry_run_section"; then
echo "e2e-dry-run should run the root CLI E2E harness package without the DryRun/Regression filter"
exit 1
fi
if ! grep -Fq "No dry-run CLI E2E needed" <<<"$dry_run_section"; then
echo "e2e-dry-run should explicitly skip when domain mode is skip"
exit 1
fi
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$section" ||
! grep -Fq "id: e2e_domains" <<<"$section" ||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$section"; then
echo "e2e-live should resolve changed-file CLI E2E domains before credentials and tests"
exit 1
fi
if ! grep -Fq "steps.e2e_domains.outputs.live_packages" <<<"$section"; then
echo "e2e-live should use resolved live_packages instead of always running the full suite"
exit 1
fi
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$section" ||
! grep -Fq 'echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$section"; then
echo "e2e-live should pass dynamic domain output through env before shell use"
exit 1
fi
if ! awk '
/^ - name: Build lark-cli/ { in_step = 1 }
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
END { exit found ? 0 : 1 }
' <<<"$dry_run_section"; then
echo "e2e-dry-run should skip building lark-cli when domain mode is skip"
exit 1
fi
if ! awk '
/^ - name: Build lark-cli/ { in_step = 1 }
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
END { exit found ? 0 : 1 }
' <<<"$section"; then
echo "e2e-live should skip building lark-cli when domain mode is skip"
exit 1
fi
if ! grep -Fq "permissions:" <<<"$section" ||
! grep -Fq "contents: read" <<<"$section" ||
! grep -Fq "checks: write" <<<"$section"; then
@@ -237,13 +304,23 @@ if ! grep -Fq "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_A
exit 1
fi
if ! awk '
/^ - name: Configure bot credentials/ { in_step = 1 }
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
in_step && /^ - name:/ && !/Configure bot credentials/ { in_step = 0 }
END { exit found ? 0 : 1 }
' <<<"$section"; then
echo "e2e-live should only configure bot credentials when domain mode is not skip"
exit 1
fi
if grep -Fq "steps.live_e2e_credentials.outputs.configured" <<<"$section"; then
echo "e2e-live build, configure, test, and report steps should not be gated by a skip-state output"
exit 1
fi
if ! grep -Fq "if: \${{ !cancelled() }}" <<<"$section"; then
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled"
if ! grep -Fq "if: \${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}" <<<"$section"; then
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled or domain mode is skip"
exit 1
fi

54
scripts/domain-map.js Normal file
View File

@@ -0,0 +1,54 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("node:fs");
const path = require("node:path");
const DOMAIN_MAP_PATH = path.join(__dirname, "domain-map.json");
const domainMap = JSON.parse(fs.readFileSync(DOMAIN_MAP_PATH, "utf8"));
function normalizeRepoPath(input) {
return String(input || "").trim().replace(/\\/g, "/").replace(/^\.\//, "").toLowerCase();
}
const pathMappingsBySpecificity = (domainMap.pathMappings || [])
.map((entry) => ({ ...entry, prefix: normalizeRepoPath(entry.prefix) }))
.sort((a, b) => b.prefix.length - a.prefix.length);
function findPathMapping(filePath) {
const normalized = normalizeRepoPath(filePath);
return pathMappingsBySpecificity.find((entry) => normalized.startsWith(entry.prefix));
}
function labelDomainsForPath(filePath) {
const mapping = findPathMapping(filePath);
return mapping ? [...(mapping.labelDomains || [])] : [];
}
function e2eDomainsForPath(filePath) {
const mapping = findPathMapping(filePath);
return mapping ? [...(mapping.e2eDomains || [])] : [];
}
function matchesFullFallback(filePath) {
const normalized = normalizeRepoPath(filePath);
return (domainMap.fullFallbackPrefixes || []).some((prefix) => normalized.startsWith(prefix));
}
function isSkippablePath(filePath) {
const normalized = normalizeRepoPath(filePath);
const basename = path.posix.basename(normalized);
return (domainMap.skipPrefixes || []).some((prefix) => normalized.startsWith(prefix))
|| (domainMap.skipSuffixes || []).some((suffix) => normalized.endsWith(suffix))
|| (domainMap.skipFilenames || []).includes(basename);
}
module.exports = {
domainMap,
e2eDomainsForPath,
findPathMapping,
isSkippablePath,
labelDomainsForPath,
matchesFullFallback,
normalizeRepoPath,
};

71
scripts/domain-map.json Normal file
View File

@@ -0,0 +1,71 @@
{
"pathMappings": [
{ "prefix": "shortcuts/im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
{ "prefix": "shortcuts/vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
{ "prefix": "shortcuts/calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
{ "prefix": "shortcuts/doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
{ "prefix": "shortcuts/sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
{ "prefix": "shortcuts/drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
{ "prefix": "shortcuts/wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
{ "prefix": "shortcuts/base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
{ "prefix": "shortcuts/mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
{ "prefix": "shortcuts/task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
{ "prefix": "shortcuts/contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
{ "prefix": "shortcuts/apps/", "labelDomains": [], "e2eDomains": ["apps"] },
{ "prefix": "shortcuts/markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
{ "prefix": "shortcuts/minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
{ "prefix": "shortcuts/okr/", "labelDomains": [], "e2eDomains": ["okr"] },
{ "prefix": "shortcuts/slides/", "labelDomains": [], "e2eDomains": ["slides"] },
{ "prefix": "shortcuts/note/", "labelDomains": [], "e2eDomains": ["note"] },
{ "prefix": "shortcuts/event/", "labelDomains": [], "e2eDomains": ["event"] },
{ "prefix": "skills/lark-im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
{ "prefix": "skills/lark-vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
{ "prefix": "skills/lark-doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
{ "prefix": "skills/lark-wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
{ "prefix": "skills/lark-drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
{ "prefix": "skills/lark-sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
{ "prefix": "skills/lark-base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
{ "prefix": "skills/lark-mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
{ "prefix": "skills/lark-calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
{ "prefix": "skills/lark-task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
{ "prefix": "skills/lark-contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
{ "prefix": "skills/lark-apps/", "labelDomains": [], "e2eDomains": ["apps"] },
{ "prefix": "skills/lark-markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
{ "prefix": "skills/lark-minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
{ "prefix": "skills/lark-okr/", "labelDomains": [], "e2eDomains": ["okr"] },
{ "prefix": "skills/lark-slides/", "labelDomains": [], "e2eDomains": ["slides"] },
{ "prefix": "skills/lark-note/", "labelDomains": [], "e2eDomains": ["note"] },
{ "prefix": "skills/lark-event/", "labelDomains": [], "e2eDomains": ["event"] }
],
"fullFallbackPrefixes": [
"shortcuts/common/",
"cmd/",
"internal/",
"pkg/",
"extension/",
"registry/",
"go.mod",
"go.sum",
"Makefile",
".github/workflows/",
"scripts/"
],
"skipPrefixes": [
"docs/",
".changeset/"
],
"skipSuffixes": [
".md",
".mdx",
".txt",
".rst"
],
"skipFilenames": [
"readme.md",
"readme.zh.md",
"changelog.md",
"license",
"cla.md"
]
}

224
scripts/e2e_domains.js Normal file
View File

@@ -0,0 +1,224 @@
#!/usr/bin/env node
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const {
e2eDomainsForPath,
findPathMapping,
isSkippablePath,
matchesFullFallback,
normalizeRepoPath,
} = require("./domain-map");
const ROOT = process.env.E2E_DOMAINS_ROOT || path.join(__dirname, "..");
process.chdir(ROOT);
function execLines(command, args) {
return execFileSync(command, args, { encoding: "utf8" })
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}
function modulePath() {
return execLines("go", ["list", "-m"])[0];
}
function rootPackage(moduleName) {
return `${moduleName}/tests/cli_e2e`;
}
function allLivePackages(moduleName) {
return execLines("go", ["list", "./tests/cli_e2e/..."])
.filter((pkg) => pkg !== rootPackage(moduleName))
.filter((pkg) => !pkg.endsWith("/demo"));
}
function allDryPackages(moduleName) {
return allLivePackages(moduleName);
}
const domainExistsCache = new Map();
function domainExists(domain) {
if (domainExistsCache.has(domain)) {
return domainExistsCache.get(domain);
}
let exists = false;
try {
execFileSync("go", ["list", `./tests/cli_e2e/${domain}`], { stdio: "ignore" });
exists = true;
} catch {
exists = false;
}
domainExistsCache.set(domain, exists);
return exists;
}
function readChangedFiles() {
const changedFilesPath = process.env.E2E_DOMAIN_CHANGED_FILES;
if (changedFilesPath) {
return fs.readFileSync(changedFilesPath, "utf8")
.split(/\r?\n/)
.map(normalizeRepoPath)
.filter(Boolean);
}
if (process.env.GITHUB_EVENT_NAME !== "pull_request") {
return null;
}
const baseRef = process.env.GITHUB_BASE_REF || "main";
try {
execFileSync("git", ["rev-parse", "--verify", `origin/${baseRef}`], { stdio: "ignore" });
return execLines("git", ["diff", "--name-only", `origin/${baseRef}...HEAD`]).map(normalizeRepoPath);
} catch {
return null;
}
}
function addDomain(domains, domain) {
if (domain && domainExists(domain)) {
domains.add(domain);
return true;
}
return false;
}
function classifyPath(filePath, domains) {
const normalized = normalizeRepoPath(filePath);
if (!normalized) return { matched: false };
const e2eMatch = normalized.match(/^tests\/cli_e2e\/([^/]+)\//);
if (e2eMatch) {
const domain = e2eMatch[1];
if (domain === "demo") return { matched: false };
if (domainExists(domain)) {
addDomain(domains, domain);
return { matched: true };
}
if (isSkippablePath(normalized)) return { matched: false };
return { fullReason: `unknown CLI E2E domain path: ${normalized}` };
}
if (normalized.startsWith("tests/cli_e2e/")) {
return { fullReason: `shared CLI E2E harness changed: ${normalized}` };
}
if (matchesFullFallback(normalized)) {
return { fullReason: `shared/runtime path changed: ${normalized}` };
}
const mappedDomains = e2eDomainsForPath(normalized);
if (mappedDomains.length > 0) {
const missingDomains = [];
for (const domain of mappedDomains) {
if (!addDomain(domains, domain)) missingDomains.push(domain);
}
if (missingDomains.length > 0) {
return { fullReason: `mapped CLI E2E domain has no package: ${missingDomains.join(",")} (${normalized})` };
}
return { matched: true };
}
if (findPathMapping(normalized)) {
return { fullReason: `mapped path has no CLI E2E package: ${normalized}` };
}
if (normalized.match(/^shortcuts\/[^/]+\//) || normalized.match(/^skills\/lark-[^/]+\//)) {
return { fullReason: `unmapped CLI E2E domain path: ${normalized}` };
}
if (isSkippablePath(normalized)) return { matched: false };
return { fullReason: `unclassified path changed: ${normalized}` };
}
function resolveDomains(changedFiles) {
const moduleName = modulePath();
const rootDryPackage = rootPackage(moduleName);
if (changedFiles === null) {
return {
mode: "full",
reason: "non-pull_request run or unavailable diff",
domains: ["all"],
dryRootPackage: rootDryPackage,
dryPackages: allDryPackages(moduleName),
livePackages: allLivePackages(moduleName),
};
}
const domains = new Set();
let matchedRelevant = false;
let fullReason = "";
for (const file of changedFiles) {
const result = classifyPath(file, domains);
if (result.matched) matchedRelevant = true;
if (result.fullReason && !fullReason) fullReason = result.fullReason;
}
if (fullReason) {
return {
mode: "full",
reason: fullReason,
domains: ["all"],
dryRootPackage: rootDryPackage,
dryPackages: allDryPackages(moduleName),
livePackages: allLivePackages(moduleName),
};
}
if (matchedRelevant && domains.size > 0) {
const sortedDomains = [...domains].sort();
const packages = sortedDomains.map((domain) => `${moduleName}/tests/cli_e2e/${domain}`);
return {
mode: "subset",
reason: "business domain changes",
domains: sortedDomains,
dryRootPackage: rootDryPackage,
dryPackages: packages,
livePackages: packages,
};
}
return {
mode: "skip",
reason: "docs-only or no live CLI E2E impact",
domains: [],
dryRootPackage: "",
dryPackages: [],
livePackages: [],
};
}
function emit(resolved) {
const values = {
mode: resolved.mode,
reason: resolved.reason,
domains: resolved.domains.join(","),
dry_root_package: resolved.dryRootPackage,
dry_packages: resolved.dryPackages.join(" "),
live_packages: resolved.livePackages.join(" "),
};
const lines = Object.entries(values).map(([key, value]) => `${key}=${value}`);
console.log(lines.join("\n"));
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join("\n")}\n`);
}
}
if (require.main === module) {
emit(resolveDomains(readChangedFiles()));
}
module.exports = {
classifyPath,
readChangedFiles,
resolveDomains,
};

View File

@@ -0,0 +1,94 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const test = require("node:test");
const scriptPath = path.join(__dirname, "e2e_domains.js");
function parseOutput(raw) {
const result = {};
for (const line of raw.trim().split(/\r?\n/)) {
const idx = line.indexOf("=");
if (idx === -1) continue;
result[line.slice(0, idx)] = line.slice(idx + 1);
}
return result;
}
function runDomains(files) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-domains-"));
const file = path.join(dir, "changed.txt");
fs.writeFileSync(file, `${files.join("\n")}\n`);
try {
return parseOutput(execFileSync(process.execPath, [scriptPath], {
cwd: path.join(__dirname, ".."),
encoding: "utf8",
env: { ...process.env, E2E_DOMAIN_CHANGED_FILES: file },
}));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
test("maps shortcut changes to one business domain package", () => {
const output = runDomains(["shortcuts/im/messages/send.go"]);
assert.equal(output.mode, "subset");
assert.equal(output.domains, "im");
assert.match(output.dry_root_package, /github\.com\/larksuite\/cli\/tests\/cli_e2e$/);
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/im/);
assert.doesNotMatch(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
});
test("maps doc shortcuts to docs package", () => {
const output = runDomains(["shortcuts/doc/update.go"]);
assert.equal(output.mode, "subset");
assert.equal(output.domains, "docs");
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/docs/);
});
test("maps direct e2e domain package changes", () => {
const output = runDomains(["tests/cli_e2e/drive/helpers.go"]);
assert.equal(output.mode, "subset");
assert.equal(output.domains, "drive");
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
});
test("falls back to full for shared e2e harness changes", () => {
const output = runDomains(["tests/cli_e2e/core.go"]);
assert.equal(output.mode, "full");
assert.equal(output.domains, "all");
assert.match(output.reason, /shared CLI E2E harness changed/);
});
test("falls back to full for runtime changes", () => {
const output = runDomains(["cmd/root.go"]);
assert.equal(output.mode, "full");
assert.equal(output.domains, "all");
assert.match(output.reason, /shared\/runtime path changed/);
});
test("skips docs-only changes", () => {
const output = runDomains(["docs/usage.md", "README.md"]);
assert.equal(output.mode, "skip");
assert.equal(output.domains, "");
assert.equal(output.dry_root_package, "");
assert.equal(output.live_packages, "");
});
test("uses shared map for skill domain changes", () => {
const output = runDomains(["skills/lark-sheets/SKILL.md"]);
assert.equal(output.mode, "subset");
assert.equal(output.domains, "sheets");
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/sheets/);
});
test("falls back to full when a mapped path has no e2e package", () => {
const output = runDomains(["shortcuts/whiteboard/export.go"]);
assert.equal(output.mode, "full");
assert.match(output.reason, /unmapped CLI E2E domain path/);
});

View File

@@ -4,6 +4,7 @@
const fs = require("node:fs/promises");
const path = require("node:path");
const { labelDomainsForPath } = require("../domain-map");
// ============================================================================
// Constants & Configuration
@@ -35,33 +36,6 @@ const CORE_PREFIXES = ["internal/auth/", "internal/engine/", "internal/config/",
const HEAD_BUSINESS_DOMAINS = new Set(["im", "contact", "ccm", "base", "docx"]);
const LOW_RISK_TYPES = new Set(["docs", "ci", "test", "chore"]);
// CODEOWNERS-based path to domain label mapping
// Maps shortcuts and skills paths to business domain labels
const PATH_TO_DOMAIN_MAP = {
// shortcuts
"shortcuts/im/": "im",
"shortcuts/vc/": "vc",
"shortcuts/calendar/": "calendar",
"shortcuts/doc/": "ccm",
"shortcuts/sheets/": "ccm",
"shortcuts/drive/": "ccm",
"shortcuts/wiki/": "ccm",
"shortcuts/base/": "base",
"shortcuts/mail/": "mail",
"shortcuts/task/": "task",
"shortcuts/contact/": "contact",
// skills
"skills/lark-im/": "im",
"skills/lark-vc/": "vc",
"skills/lark-doc/": "ccm",
"skills/lark-wiki/": "ccm",
"skills/lark-base/": "base",
"skills/lark-mail/": "mail",
"skills/lark-calendar/": "calendar",
"skills/lark-task/": "task",
"skills/lark-contact/": "contact",
};
const SENSITIVE_PATTERN = /(^|\/)(auth|permission|permissions|security)(\/|_|\.|$)/;
const CLASS_STANDARDS = {
@@ -285,13 +259,7 @@ function skillDomainForPath(filePath) {
// Get business domain label based on CODEOWNERS path mapping
function getBusinessDomain(filePath) {
const normalized = normalizePath(filePath);
for (const [prefix, domain] of Object.entries(PATH_TO_DOMAIN_MAP)) {
if (normalized.startsWith(prefix)) {
return domain;
}
}
return "";
return labelDomainsForPath(filePath)[0] || "";
}
async function detectNewShortcutDomain(files) {

View File

@@ -8,7 +8,17 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
script="$repo_root/scripts/resolve-changed-from.sh"
tmp="${TMPDIR:-/tmp}/resolve-changed-from-test-$$"
trap 'rm -rf "$tmp"' EXIT
cleanup_tmp() {
local attempt
for attempt in 1 2 3; do
rm -rf "$tmp" && return 0
sleep 1
done
rm -rf "$tmp"
}
trap cleanup_tmp EXIT
mkdir -p "$tmp"
git_init() {

View 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,
}
}

View File

@@ -0,0 +1,98 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"strings"
"github.com/larksuite/cli/errs"
)
// 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
}
// 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 strings.Contains(p.Message, "command already exists")
}

View File

@@ -0,0 +1,155 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"strings"
"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 {
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 || !strings.Contains(err.Error(), "duplicate language") {
t.Fatalf("expected duplicate language error, got %v", err)
}
}
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")
}
}
// 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
}

View File

@@ -0,0 +1,117 @@
// 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/internal/validate"
"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 PATCH it instead (like `gh label create --force`)"},
},
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 {
return common.NewDryRunAPI().Set("error", err.Error())
}
body := buildSlashCommandBody(runtime.Str("command"), 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 := 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(p.Subtype, "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+"/"+validate.EncodePathSegment(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
},
}

View File

@@ -0,0 +1,182 @@
// 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"
)
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 !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 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")
}
if !strings.Contains(err.Error(), "icon_key") {
t.Fatalf("expected original icon_key failure to surface unchanged, got %v", err)
}
}
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)
}
}

View File

@@ -0,0 +1,83 @@
// 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/internal/validate"
"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 := runtime.Str("command-id")
if target == "" {
d.GET(slashCommandBasePath).
Desc(fmt.Sprintf("resolve command_id by name %q via GET list first", runtime.Str("command")))
target = "<resolved_command_id>"
}
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+"/"+validate.EncodePathSegment(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
},
}

View File

@@ -0,0 +1,109 @@
// 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{}
_ = json.Unmarshal(stdout.Bytes(), &got)
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)
}
out := stdout.String()
// 两条 Desc 都必须保留top-level HIGH-RISK 说明和 GET 调用的 resolve 说明
// 不能被覆盖DryRunAPI.Desc 在没有 call 时设置 top-levelappend 后设置 per-call
if !strings.Contains(out, "HIGH-RISK") {
t.Fatalf("dry-run must keep top-level HIGH-RISK desc: %s", out)
}
if !strings.Contains(out, "resolve command_id") {
t.Fatalf("dry-run must keep per-call resolve desc: %s", out)
}
}
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 {
t.Errorf("%v: expected validation problem, got %v", args, err)
}
}
}

View 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 current bound Open Platform app; source of command_id for update/delete (NOT for Miaoda apps - use the apps domain for those)",
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
},
}

View 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)
}
}

View File

@@ -0,0 +1,53 @@
// 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"
)
// matchCommandItem finds the item whose "command" equals name (exact match -
// the server enforces name uniqueness, so first hit is the only hit).
func matchCommandItem(items []interface{}, name string) (string, map[string]interface{}) {
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, m
}
}
}
return "", nil
}
// 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, map[string]interface{}, error) {
data, err := runtime.CallAPITyped("GET", slashCommandBasePath, nil, nil)
if err != nil {
return "", nil, err
}
items, _ := data["items"].([]interface{})
id, item := matchCommandItem(items, name)
if id == "" {
return "", nil, commandNotFoundError(name)
}
return id, item, nil
}

View File

@@ -0,0 +1,39 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"strings"
"testing"
)
func TestMatchCommandItem(t *testing.T) {
items := []interface{}{
sampleItem("greet", "id1"),
sampleItem("weather", "id2"),
}
id, item := matchCommandItem(items, "weather")
if id != "id2" || item == nil {
t.Fatalf("got id=%q item=%v", id, item)
}
id, item = matchCommandItem(items, "nope")
if id != "" || item != nil {
t.Fatalf("miss should return empty, got id=%q", id)
}
// 精确匹配:大小写与空白不做宽容
id, _ = matchCommandItem(items, "Greet")
if id != "" {
t.Fatalf("match must be exact, got %q", id)
}
}
func TestResolveNotFoundErrorShape(t *testing.T) {
err := commandNotFoundError("nope")
if err == nil || !strings.Contains(err.Error(), `"nope"`) {
t.Fatalf("err = %v", err)
}
if !strings.Contains(err.Error(), "not found") {
t.Fatalf("err should say not found: %v", err)
}
}

View File

@@ -0,0 +1,119 @@
// 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/internal/validate"
"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; conservative rule, see spec amendment #3).
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 {
return common.NewDryRunAPI().Set("error", err.Error())
}
body := buildSlashCommandBody("", runtime.Str("description"), i18n, runtime.Str("icon-key"))
d := common.NewDryRunAPI()
target := runtime.Str("command-id")
if target == "" {
d.Desc(fmt.Sprintf("resolve command_id by name %q via GET list first", runtime.Str("command"))).
GET(slashCommandBasePath)
target = "<resolved_command_id>"
}
return d.PATCH(slashCommandBasePath + "/" + target).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+"/"+validate.EncodePathSegment(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
},
}

View File

@@ -0,0 +1,79 @@
// 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 || !strings.Contains(err.Error(), "not found") {
t.Fatalf("expected not found, got %v", err)
}
}
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 {
t.Errorf("%s: expected validation problem, got %v", c.name, err)
}
}
}

View File

@@ -17,6 +17,7 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts/application"
"github.com/larksuite/cli/shortcuts/apps"
"github.com/larksuite/cli/shortcuts/base"
"github.com/larksuite/cli/shortcuts/calendar"
@@ -61,6 +62,7 @@ var allShortcuts []common.Shortcut
func init() {
allShortcuts = append(allShortcuts, apps.Shortcuts()...)
allShortcuts = append(allShortcuts, application.Shortcuts()...)
allShortcuts = append(allShortcuts, calendar.Shortcuts()...)
allShortcuts = append(allShortcuts, doc.Shortcuts()...)
allShortcuts = append(allShortcuts, drive.Shortcuts()...)

View File

@@ -0,0 +1,166 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// setSlashCommandDryRunEnv isolates config and supplies stub credentials so
// dry-run / the pre-Execute confirmation gate short-circuit before identity
// resolution touches a real keychain. Mirrors tests/cli_e2e/apps/helpers_test.go
// and tests/cli_e2e/calendar/calendar_update_dryrun_test.go.
func setSlashCommandDryRunEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "application_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "application_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}
const slashCommandBasePath = "/open-apis/application/v7/app_slash_commands"
// TestSlashCommandList_DryRunShowsGetPath pins the read-only GET shape for
// `application +slash-command-list --dry-run`.
func TestSlashCommandList_DryRunShowsGetPath(t *testing.T) {
setSlashCommandDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"application", "+slash-command-list",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
assert.Equal(t, "GET", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
assert.Equal(t, slashCommandBasePath, gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
}
// TestSlashCommandCreate_DryRunShowsPostBody pins the POST body shape for
// `application +slash-command-create --dry-run`: icon sits at the TOP LEVEL,
// a sibling of description (not nested inside description) - the official
// create sample nesting icon inside description is a documented doc bug -
// and description.i18n carries the localized map.
func TestSlashCommandCreate_DryRunShowsPostBody(t *testing.T) {
setSlashCommandDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"application", "+slash-command-create",
"--command", "greet",
"--description", "say hi",
"--description-i18n", "zh_cn=你好",
"--icon-key", "skill_outlined",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
assert.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
assert.Equal(t, slashCommandBasePath, gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
assert.Equal(t, "greet", gjson.Get(out, "api.0.body.command").String(), "stdout:\n%s", out)
assert.Equal(t, "say hi", gjson.Get(out, "api.0.body.description.default_value").String(), "stdout:\n%s", out)
assert.Equal(t, "你好", gjson.Get(out, "api.0.body.description.i18n.zh_cn").String(), "stdout:\n%s", out)
// icon is a top-level key, sibling of description.
assert.Equal(t, "skill_outlined", gjson.Get(out, "api.0.body.icon.icon_key").String(), "stdout:\n%s", out)
assert.False(t, gjson.Get(out, "api.0.body.description.icon").Exists(), "icon must not be nested inside description:\n%s", out)
}
// TestSlashCommandUpdate_DryRunShowsPatchPath pins the PATCH shape for
// `application +slash-command-update --command-id --dry-run`.
func TestSlashCommandUpdate_DryRunShowsPatchPath(t *testing.T) {
setSlashCommandDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"application", "+slash-command-update",
"--command-id", "id_dry",
"--description", "updated description",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
assert.Equal(t, "PATCH", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
assert.Equal(t, slashCommandBasePath+"/id_dry", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
assert.Equal(t, "updated description", gjson.Get(out, "api.0.body.description.default_value").String(), "stdout:\n%s", out)
}
// TestSlashCommandDelete_DryRunShowsDeletePath pins the DELETE shape for
// `application +slash-command-delete --command-id --yes --dry-run`. Dry-run
// short-circuits before the high-risk-write confirmation gate (see
// shortcuts/common/runner.go), but --yes is passed anyway to match the
// eventual real invocation the agent would run.
func TestSlashCommandDelete_DryRunShowsDeletePath(t *testing.T) {
setSlashCommandDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"application", "+slash-command-delete",
"--command-id", "id_dry",
"--dry-run",
},
Yes: true,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
assert.Equal(t, "DELETE", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
assert.Equal(t, slashCommandBasePath+"/id_dry", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
}
// TestSlashCommandDelete_WithoutYesRequiresConfirmation asserts the
// high-risk-write gate fires BEFORE any HTTP call: no --dry-run, no --yes ->
// exit 10 (ExitConfirmationRequired) with a confirmation_required envelope on
// stderr (see internal/output/exitcode.go and cmd/root.go handleRootError).
func TestSlashCommandDelete_WithoutYesRequiresConfirmation(t *testing.T) {
setSlashCommandDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"application", "+slash-command-delete",
"--command-id", "id_dry",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 10)
assert.Equal(t, "confirmation", gjson.Get(result.Stderr, "error.type").String(), "stderr:\n%s", result.Stderr)
assert.Equal(t, "confirmation_required", gjson.Get(result.Stderr, "error.subtype").String(), "stderr:\n%s", result.Stderr)
}

View File

@@ -24,7 +24,17 @@ import (
const EnvBinaryPath = "LARK_CLI_BIN"
const projectRootMarkerDir = "tests"
const cliBinaryName = "lark-cli"
const CleanupTimeout = 30 * time.Second
const (
// CleanupTimeout is the outer teardown budget. Keep it above any
// per-resource wait so cleanup command retries still have room to run.
CleanupTimeout = 60 * time.Second
defaultRetryAttempts = 4
defaultRetryInitialDelay = time.Second
defaultRetryMaxDelay = 6 * time.Second
defaultRetryBackoffMultiple = 2
)
func SkipWithoutUserToken(t *testing.T) {
t.Helper()
@@ -102,6 +112,34 @@ type Result struct {
RunErr error
}
type cleanupWarningError struct {
err error
}
func (e *cleanupWarningError) Error() string {
return e.err.Error()
}
func (e *cleanupWarningError) Unwrap() error {
return e.err
}
// CleanupWarning marks a cleanup verification issue as non-fatal after the
// destructive cleanup command itself has already succeeded.
func CleanupWarning(err error) error {
if err == nil {
return nil
}
return &cleanupWarningError{err: err}
}
// IsCleanupWarning reports whether err should be logged without failing the
// parent test.
func IsCleanupWarning(err error) bool {
var warning *cleanupWarningError
return errors.As(err, &warning)
}
// RetryOptions configures retry behavior for flaky external API calls.
type RetryOptions struct {
Attempts int
@@ -111,8 +149,25 @@ type RetryOptions struct {
ShouldRetry func(*Result) bool
}
// WaitOptions configures a bounded poll loop for eventually consistent cleanup
// or verification checks.
type WaitOptions struct {
Timeout time.Duration
Interval time.Duration
TimeoutError func() error
}
// RunCmd executes lark-cli and captures stdout/stderr/exit code.
// Service errors that return {"error":{"retryable":true}} are retried with
// bounded exponential backoff so individual tests do not need to remember
// RunCmdWithRetry for normal transient server contention.
func RunCmd(ctx context.Context, req Request) (*Result, error) {
return RunCmdWithRetry(ctx, req, RetryOptions{
ShouldRetry: ResultHasRetryableError,
})
}
func runCmdOnce(ctx context.Context, req Request) (*Result, error) {
binaryPath, err := ResolveBinaryPath(req)
if err != nil {
return nil, err
@@ -186,16 +241,16 @@ func buildCommandEnv(req Request) []string {
// RunCmdWithRetry reruns a command when the result matches the configured retry condition.
func RunCmdWithRetry(ctx context.Context, req Request, opts RetryOptions) (*Result, error) {
if opts.Attempts <= 0 {
opts.Attempts = 4
opts.Attempts = defaultRetryAttempts
}
if opts.InitialDelay <= 0 {
opts.InitialDelay = 1 * time.Second
opts.InitialDelay = defaultRetryInitialDelay
}
if opts.MaxDelay <= 0 {
opts.MaxDelay = 6 * time.Second
opts.MaxDelay = defaultRetryMaxDelay
}
if opts.BackoffMultiple <= 1 {
opts.BackoffMultiple = 2
opts.BackoffMultiple = defaultRetryBackoffMultiple
}
if opts.ShouldRetry == nil {
opts.ShouldRetry = func(result *Result) bool {
@@ -206,7 +261,7 @@ func RunCmdWithRetry(ctx context.Context, req Request, opts RetryOptions) (*Resu
delay := opts.InitialDelay
var lastResult *Result
for attempt := 1; attempt <= opts.Attempts; attempt++ {
result, err := RunCmd(ctx, req)
result, err := runCmdOnce(ctx, req)
if err != nil {
return nil, err
}
@@ -234,6 +289,63 @@ func RunCmdWithRetry(ctx context.Context, req Request, opts RetryOptions) (*Resu
return lastResult, nil
}
// ResultHasRetryableError reports whether lark-cli returned a structured
// service error with error.retryable=true in either output stream.
func ResultHasRetryableError(result *Result) bool {
if result == nil {
return false
}
return rawHasRetryableError(result.Stdout) || rawHasRetryableError(result.Stderr)
}
func rawHasRetryableError(raw string) bool {
payload := extractJSONPayload(raw)
if payload == "" {
return false
}
return gjson.Get(payload, "error.retryable").Bool()
}
// WaitForCondition polls condition until it returns true, an error, the context
// is canceled, or the configured timeout expires.
func WaitForCondition(ctx context.Context, opts WaitOptions, condition func() (bool, error)) error {
if condition == nil {
return errors.New("wait condition is nil")
}
if opts.Timeout <= 0 {
opts.Timeout = CleanupTimeout
}
if opts.Interval <= 0 {
opts.Interval = time.Second
}
deadline := time.NewTimer(opts.Timeout)
defer deadline.Stop()
ticker := time.NewTicker(opts.Interval)
defer ticker.Stop()
for {
done, err := condition()
if err != nil {
return err
}
if done {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-deadline.C:
if opts.TimeoutError != nil {
return opts.TimeoutError()
}
return fmt.Errorf("condition still false after %s", opts.Timeout)
case <-ticker.C:
}
}
}
// GenerateSuffix returns a high-entropy UTC timestamp suffix suitable for remote test resource names.
func GenerateSuffix() string {
now := time.Now().UTC()
@@ -251,6 +363,10 @@ func ReportCleanupFailure(parentT *testing.T, prefix string, result *Result, err
parentT.Helper()
if err != nil {
if IsCleanupWarning(err) {
parentT.Logf("%s: %v", prefix, err)
return
}
parentT.Errorf("%s: %v", prefix, err)
return
}
@@ -271,26 +387,11 @@ func isCleanupSuppressedResult(result *Result) bool {
return false
}
raw := strings.TrimSpace(result.Stdout)
if raw == "" {
raw = strings.TrimSpace(result.Stderr)
payload := extractJSONPayload(result.Stdout)
if payload == "" {
payload = extractJSONPayload(result.Stderr)
}
if raw == "" {
return false
}
start := strings.LastIndex(raw, "\n{")
if start >= 0 {
start++
} else {
start = strings.Index(raw, "{")
}
if start < 0 {
return false
}
payload := raw[start:]
if !gjson.Valid(payload) {
if payload == "" {
return false
}
@@ -306,6 +407,32 @@ func isCleanupSuppressedResult(result *Result) bool {
return errType == "api_error" && (errCode == 800004135 || strings.Contains(errMessage, " limited"))
}
func extractJSONPayload(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if gjson.Valid(raw) {
return raw
}
start := strings.LastIndex(raw, "\n{")
if start >= 0 {
start++
} else {
start = strings.Index(raw, "{")
}
if start < 0 {
return ""
}
payload := raw[start:]
if !gjson.Valid(payload) {
return ""
}
return payload
}
// ResolveBinaryPath finds the CLI binary path using request, env, then PATH.
func ResolveBinaryPath(req Request) (string, error) {
if req.BinaryPath != "" {

View File

@@ -5,10 +5,12 @@ package clie2e
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -223,6 +225,88 @@ func TestRunCmd(t *testing.T) {
assert.NotContains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
assert.NotContains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
})
t.Run("retries structured retryable service errors by default", func(t *testing.T) {
fake := newFakeCLI(t)
statePath := filepath.Join(t.TempDir(), "retry-count")
result, err := RunCmd(context.Background(), Request{
BinaryPath: fake.BinaryPath,
Args: []string{"fail-once-retryable", statePath},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
countBytes, err := os.ReadFile(statePath)
require.NoError(t, err)
assert.Equal(t, "2\n", string(countBytes))
})
t.Run("does not retry non-retryable service errors by default", func(t *testing.T) {
fake := newFakeCLI(t)
statePath := filepath.Join(t.TempDir(), "retry-count")
result, err := RunCmd(context.Background(), Request{
BinaryPath: fake.BinaryPath,
Args: []string{"always-non-retryable", statePath},
})
require.NoError(t, err)
result.AssertExitCode(t, 1)
countBytes, err := os.ReadFile(statePath)
require.NoError(t, err)
assert.Equal(t, "1\n", string(countBytes))
})
}
func TestRunCmdWithRetry(t *testing.T) {
t.Run("does not include RunCmd default retry as a nested retry", func(t *testing.T) {
fake := newFakeCLI(t)
statePath := filepath.Join(t.TempDir(), "retry-count")
result, err := RunCmdWithRetry(context.Background(), Request{
BinaryPath: fake.BinaryPath,
Args: []string{"fail-once-retryable", statePath},
}, RetryOptions{
Attempts: 1,
InitialDelay: time.Millisecond,
MaxDelay: time.Millisecond,
ShouldRetry: ResultHasRetryableError,
})
require.NoError(t, err)
result.AssertExitCode(t, 1)
countBytes, err := os.ReadFile(statePath)
require.NoError(t, err)
assert.Equal(t, "1\n", string(countBytes))
})
}
func TestWaitForCondition(t *testing.T) {
t.Run("polls until condition succeeds", func(t *testing.T) {
attempts := 0
err := WaitForCondition(context.Background(), WaitOptions{
Timeout: 50 * time.Millisecond,
Interval: time.Millisecond,
}, func() (bool, error) {
attempts++
return attempts == 2, nil
})
require.NoError(t, err)
assert.Equal(t, 2, attempts)
})
t.Run("returns custom timeout error", func(t *testing.T) {
wantErr := errors.New("still visible")
err := WaitForCondition(context.Background(), WaitOptions{
Timeout: time.Millisecond,
Interval: time.Millisecond,
TimeoutError: func() error { return wantErr },
}, func() (bool, error) {
return false, nil
})
assert.ErrorIs(t, err, wantErr)
})
}
type fakeCLI struct {
@@ -260,6 +344,35 @@ if [ "$1" = "emit-stdin" ]; then
exit 0
fi
if [ "$1" = "fail-once-retryable" ]; then
state="$2"
count=0
if [ -f "$state" ]; then
count="$(cat "$state")"
fi
count=$((count + 1))
echo "$count" > "$state"
if [ "$count" -eq 1 ]; then
echo "Deleting folder fake..." >&2
echo '{"ok":false,"error":{"type":"api","code":1061045,"message":"resource contention occurred, please retry.","retryable":true}}' >&2
exit 1
fi
echo '{"ok":true}'
exit 0
fi
if [ "$1" = "always-non-retryable" ]; then
state="$2"
count=0
if [ -f "$state" ]; then
count="$(cat "$state")"
fi
count=$((count + 1))
echo "$count" > "$state"
echo '{"ok":false,"error":{"type":"api","code":123,"message":"validation failed","retryable":false}}' >&2
exit 1
fi
exit_code=0
while [ "$#" -gt 0 ]; do
case "$1" in

View File

@@ -21,7 +21,7 @@
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | docs +create | shortcut | docs/helpers_test.go::createDocWithRetry; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/create as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/create; docs_update_dryrun_test.go::TestDocs_CreateTitleDryRunPrependsContent | `--parent-token`; `--doc-format markdown`; `--content`; `--title` | helper asserts returned doc id from `data.document.document_id`; dry-run asserts title is prepended into request body content |
| ✓ | docs +fetch | shortcut | docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc <docToken>`; `--doc-format markdown`; default `extra_param.enable_user_cite_reference_map=true` | |
| ✓ | docs +fetch | shortcut | docs_fetch_dryrun_test.go::TestDocsFetchDryRunIgnoresAPIVersionCompatFlag; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc <docToken>`; `--doc-format markdown`; default `extra_param.enable_user_cite_reference_map=true`; `--api-version v1` compatibility flag still dry-runs the v2 fetch endpoint | |
| ✓ | docs +history-list | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history list; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--page-size`; `--page-token` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` |
| ✓ | docs +history-revert | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--history-version-id`; `--wait-timeout-ms` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` |
| ✓ | docs +history-revert-status | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert status; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--task-id` | live workflow polls only when revert returns `running` |

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
package docs
import (
"context"
@@ -23,7 +23,7 @@ func TestDocsFetchDryRunIgnoresAPIVersionCompatFlag(t *testing.T) {
Args: []string{
"docs", "+fetch",
"--doc", "doxcnDryRunCompat",
"--api-version", "legacy",
"--api-version", "v1",
"--dry-run",
},
DefaultAs: "bot",

View File

@@ -14,6 +14,18 @@ import (
"github.com/tidwall/gjson"
)
const (
driveDeleteVisibilityTimeout = 30 * time.Second
driveDeleteVisibilityPoll = 3 * time.Second
)
var driveDeleteVisibilityWait = clie2e.WaitOptions{
// This wait only covers the post-delete visibility lag after Drive accepts
// deletion. The delete command itself is bounded by clie2e.CleanupContext.
Timeout: driveDeleteVisibilityTimeout,
Interval: driveDeleteVisibilityPoll,
}
// CreateDriveFolder creates a Drive folder, optionally under a parent folder, and
// deletes it during parent cleanup.
func CreateDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, name string, defaultAs string, parentFolderToken string) string {
@@ -60,14 +72,18 @@ func CreateDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, na
// returned a suppressed not_found or partial API error but the resource still
// exists.
func DeleteDriveResourceAndVerify(ctx context.Context, token, docType, defaultAs string) (*clie2e.Result, error) {
return deleteDriveResourceAndVerify(ctx, token, docType, defaultAs, driveDeleteVisibilityWait)
}
func deleteDriveResourceAndVerify(ctx context.Context, token, docType, defaultAs string, visibilityWait clie2e.WaitOptions) (*clie2e.Result, error) {
if defaultAs == "" {
defaultAs = "bot"
}
deleteResult, deleteErr := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
deleteResult, deleteErr := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"drive", "+delete", "--file-token", token, "--type", docType, "--yes"},
DefaultAs: defaultAs,
}, clie2e.RetryOptions{})
})
if deleteErr != nil || deleteResult == nil {
return deleteResult, deleteErr
}
@@ -82,35 +98,21 @@ func DeleteDriveResourceAndVerify(ctx context.Context, token, docType, defaultAs
}
return deleteResult, fmt.Errorf("drive resource %s/%s still exists after delete failed: exit=%d stdout=%s stderr=%s", docType, token, deleteResult.ExitCode, deleteResult.Stdout, deleteResult.Stderr)
}
if err := WaitDriveResourceDeleted(ctx, token, docType, defaultAs); err != nil {
return deleteResult, err
if err := waitDriveResourceDeleted(ctx, token, docType, defaultAs, visibilityWait); err != nil {
return deleteResult, clie2e.CleanupWarning(
fmt.Errorf("drive resource %s/%s still visible after accepted delete: %w", docType, token, err),
)
}
return deleteResult, nil
}
func WaitDriveResourceDeleted(ctx context.Context, token, docType, defaultAs string) error {
deadline := time.NewTimer(20 * time.Second)
defer deadline.Stop()
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
deleted, err := IsDriveResourceDeleted(ctx, token, docType, defaultAs)
if err != nil {
return err
}
if deleted {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-deadline.C:
return fmt.Errorf("drive resource %s/%s still exists after delete", docType, token)
case <-ticker.C:
}
func waitDriveResourceDeleted(ctx context.Context, token, docType, defaultAs string, opts clie2e.WaitOptions) error {
opts.TimeoutError = func() error {
return fmt.Errorf("drive resource %s/%s still exists %s after delete", docType, token, opts.Timeout)
}
return clie2e.WaitForCondition(ctx, opts, func() (bool, error) {
return IsDriveResourceDeleted(ctx, token, docType, defaultAs)
})
}
func IsDriveResourceDeleted(ctx context.Context, token, docType, defaultAs string) (bool, error) {

View File

@@ -5,8 +5,13 @@ package drive
import (
"context"
"os"
"path/filepath"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -16,3 +21,59 @@ func createDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, na
require.NotEmpty(t, folderToken)
return folderToken
}
func TestDeleteDriveResourceAndVerify(t *testing.T) {
t.Run("successful delete with stale meta returns cleanup warning", func(t *testing.T) {
fake := mustWriteDriveCleanupFakeCLI(t)
t.Setenv(clie2e.EnvBinaryPath, fake)
result, err := deleteDriveResourceAndVerify(context.Background(), "fld_stale", "folder", "bot", clie2e.WaitOptions{
Timeout: 10 * time.Millisecond,
Interval: time.Millisecond,
})
require.NotNil(t, result)
assert.Equal(t, 0, result.ExitCode)
require.Error(t, err)
assert.True(t, clie2e.IsCleanupWarning(err), "err: %v", err)
})
t.Run("failed delete with existing meta remains fatal", func(t *testing.T) {
fake := mustWriteDriveCleanupFakeCLI(t)
t.Setenv(clie2e.EnvBinaryPath, fake)
t.Setenv("FAKE_DRIVE_DELETE_EXIT", "1")
result, err := DeleteDriveResourceAndVerify(context.Background(), "fld_existing", "folder", "bot")
require.NotNil(t, result)
assert.Equal(t, 1, result.ExitCode)
require.Error(t, err)
assert.False(t, clie2e.IsCleanupWarning(err), "err: %v", err)
assert.Contains(t, err.Error(), "still exists after delete failed")
})
}
func mustWriteDriveCleanupFakeCLI(t *testing.T) string {
t.Helper()
script := `#!/bin/sh
if [ "$1" = "drive" ] && [ "$2" = "+delete" ]; then
if [ "${FAKE_DRIVE_DELETE_EXIT:-0}" != "0" ]; then
echo '{"ok":false,"error":{"type":"api","message":"delete failed"}}' >&2
exit "$FAKE_DRIVE_DELETE_EXIT"
fi
echo '{"ok":true}'
exit 0
fi
if [ "$1" = "api" ] && [ "$2" = "post" ] && [ "$3" = "/open-apis/drive/v1/metas/batch_query" ]; then
echo '{"ok":true,"data":{"metas":[{"url":"https://example.com/still-visible"}]}}'
exit 0
fi
echo "unexpected fake CLI args: $*" >&2
exit 2
`
binaryPath := filepath.Join(t.TempDir(), "fake-lark-cli")
require.NoError(t, os.WriteFile(binaryPath, []byte(script), 0o755))
return binaryPath
}

View File

@@ -5,6 +5,7 @@ package wiki
import (
"context"
"errors"
"fmt"
"strings"
"testing"
@@ -203,6 +204,11 @@ type wikiNodeInfo struct {
ObjType string
}
const (
wikiDeleteVisibilityTimeout = 30 * time.Second
wikiDeleteVisibilityPoll = 3 * time.Second
)
// deleteWikiNodeAndVerify removes a wiki node, then polls get_node until the
// original node token is gone. Wiki cleanup cannot use drive +delete because
// wiki origin nodes need the backing obj_token and parent nodes must delete
@@ -333,28 +339,34 @@ func listWikiNodeChildren(ctx context.Context, spaceID, parentNodeToken string)
}
func waitWikiNodeDeleted(ctx context.Context, nodeToken string) error {
deadline := time.NewTimer(20 * time.Second)
defer deadline.Stop()
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
var lastTransientErr error
for {
opts := clie2e.WaitOptions{
Timeout: wikiDeleteVisibilityTimeout,
Interval: wikiDeleteVisibilityPoll,
TimeoutError: func() error {
if lastTransientErr != nil {
return fmt.Errorf("wiki node %s delete verification kept hitting transient errors: %w", nodeToken, lastTransientErr)
}
return fmt.Errorf("wiki node %s still exists after delete", nodeToken)
},
}
return clie2e.WaitForCondition(ctx, opts, func() (bool, error) {
deleted, err := isWikiNodeDeleted(ctx, nodeToken)
if err != nil {
return err
if isWikiVerifyTransientError(err) {
lastTransientErr = err
return false, nil
} else {
return false, err
}
}
if deleted {
return nil
return true, nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-deadline.C:
return fmt.Errorf("wiki node %s still exists after delete", nodeToken)
case <-ticker.C:
}
}
return false, nil
})
}
func isWikiNodeDeleted(ctx context.Context, nodeToken string) (bool, error) {
@@ -375,9 +387,31 @@ func isWikiNodeDeleted(ctx context.Context, nodeToken string) (bool, error) {
if isWikiNodeDeletedResult(result) {
return true, nil
}
if isWikiVerifyTransientResult(result) {
return false, wikiVerifyTransientError{
err: fmt.Errorf("verify wiki node %s after delete hit transient response: exit=%d stdout=%s stderr=%s", nodeToken, result.ExitCode, result.Stdout, result.Stderr),
}
}
return false, fmt.Errorf("verify wiki node %s after delete: exit=%d stdout=%s stderr=%s", nodeToken, result.ExitCode, result.Stdout, result.Stderr)
}
type wikiVerifyTransientError struct {
err error
}
func (e wikiVerifyTransientError) Error() string {
return e.err.Error()
}
func (e wikiVerifyTransientError) Unwrap() error {
return e.err
}
func isWikiVerifyTransientError(err error) bool {
var transient wikiVerifyTransientError
return err != nil && errors.As(err, &transient)
}
func wikiAPISuccess(stdout string) bool {
if ok := gjson.Get(stdout, "ok"); ok.Exists() {
return ok.Bool()
@@ -404,6 +438,55 @@ func isWikiNodeDeletedResult(result *clie2e.Result) bool {
strings.Contains(combined, "not found")
}
func isWikiVerifyTransientResult(result *clie2e.Result) bool {
if result == nil {
return false
}
payload := result.Stdout
if strings.TrimSpace(payload) == "" {
payload = result.Stderr
}
if gjson.Get(payload, "error.type").String() != "internal" ||
gjson.Get(payload, "error.subtype").String() != "invalid_response" {
return false
}
message := strings.ToLower(gjson.Get(payload, "error.message").String())
return strings.Contains(message, "http 429") ||
strings.Contains(message, "http 500") ||
strings.Contains(message, "http 502") ||
strings.Contains(message, "http 503") ||
strings.Contains(message, "http 504")
}
func TestWikiVerifyTransientResult(t *testing.T) {
t.Run("matches invalid response from transient http status", func(t *testing.T) {
result := &clie2e.Result{
ExitCode: 5,
Stderr: `{"ok":false,"error":{"type":"internal","subtype":"invalid_response","message":"SDK returned an invalid JSON response: failed to parse TAT response (HTTP 429): invalid character 'r' looking for beginning of value"}}`,
}
require.True(t, isWikiVerifyTransientResult(result))
})
t.Run("does not match unrelated invalid response", func(t *testing.T) {
result := &clie2e.Result{
ExitCode: 5,
Stderr: `{"ok":false,"error":{"type":"internal","subtype":"invalid_response","message":"SDK returned an invalid JSON response: malformed body"}}`,
}
require.False(t, isWikiVerifyTransientResult(result))
})
t.Run("does not match api errors", func(t *testing.T) {
result := &clie2e.Result{
ExitCode: 1,
Stderr: `{"ok":false,"error":{"type":"api","subtype":"conflict","message":"resource contention occurred, please retry","retryable":true}}`,
}
require.False(t, isWikiVerifyTransientResult(result))
})
}
func findWikiNodeByToken(t *testing.T, ctx context.Context, spaceID string, nodeToken string, parentNodeTokens ...string) gjson.Result {
t.Helper()