mirror of
https://github.com/larksuite/cli.git
synced 2026-07-09 02:14:02 +08:00
Compare commits
23 Commits
feat/drive
...
feat/appli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
beee72f844 | ||
|
|
78acceb910 | ||
|
|
cb8d09d802 | ||
|
|
42dc50f5fb | ||
|
|
59ccffb09a | ||
|
|
d4f62490d0 | ||
|
|
d8327726b0 | ||
|
|
3a297df5b0 | ||
|
|
dce04f792a | ||
|
|
fd00d43406 | ||
|
|
f2f49a68f4 | ||
|
|
42c4b7d155 | ||
|
|
96c8e638a1 | ||
|
|
ba8368504d | ||
|
|
c3adfc1789 | ||
|
|
a290aa628f | ||
|
|
edeccaa419 | ||
|
|
84962a1927 | ||
|
|
a72a562c7d | ||
|
|
deb866f981 | ||
|
|
6f95c5eb22 | ||
|
|
4e2cbea94e | ||
|
|
f98dbfe247 |
54
.github/workflows/ci.yml
vendored
54
.github/workflows/ci.yml
vendored
@@ -263,13 +263,19 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Resolve CLI E2E domains
|
||||
id: e2e_domains
|
||||
run: node scripts/e2e_domains.js
|
||||
- name: Build lark-cli
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
run: make build
|
||||
- name: Run dry-run E2E tests
|
||||
env:
|
||||
@@ -277,7 +283,28 @@ jobs:
|
||||
LARKSUITE_CLI_APP_ID: dry-run
|
||||
LARKSUITE_CLI_APP_SECRET: dry-run
|
||||
LARKSUITE_CLI_BRAND: feishu
|
||||
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
|
||||
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
|
||||
E2E_DRY_ROOT_PACKAGE: ${{ steps.e2e_domains.outputs.dry_root_package }}
|
||||
E2E_DRY_PACKAGES: ${{ steps.e2e_domains.outputs.dry_packages }}
|
||||
run: |
|
||||
if [ "$E2E_MODE" = "skip" ]; then
|
||||
echo "No dry-run CLI E2E needed: $E2E_REASON"
|
||||
exit 0
|
||||
fi
|
||||
if [ -z "$E2E_DRY_ROOT_PACKAGE" ] && [ -z "$E2E_DRY_PACKAGES" ]; then
|
||||
echo "::error::No dry-run CLI E2E packages resolved for mode $E2E_MODE"
|
||||
exit 1
|
||||
fi
|
||||
echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"
|
||||
if [ -n "$E2E_DRY_ROOT_PACKAGE" ]; then
|
||||
echo "Dry-run CLI E2E root package: $E2E_DRY_ROOT_PACKAGE"
|
||||
go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"
|
||||
fi
|
||||
if [ -n "$E2E_DRY_PACKAGES" ]; then
|
||||
echo "Dry-run CLI E2E packages: $E2E_DRY_PACKAGES"
|
||||
go test -v -count=1 -timeout=5m $E2E_DRY_PACKAGES -run 'DryRun|Regression'
|
||||
fi
|
||||
|
||||
e2e-live:
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
@@ -292,15 +319,22 @@ jobs:
|
||||
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Resolve CLI E2E domains
|
||||
id: e2e_domains
|
||||
run: node scripts/e2e_domains.js
|
||||
- name: Build lark-cli
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
run: make build
|
||||
- name: Configure bot credentials
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
run: |
|
||||
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
|
||||
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
|
||||
@@ -310,16 +344,24 @@ jobs:
|
||||
- name: Run CLI E2E tests
|
||||
env:
|
||||
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
||||
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
|
||||
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
|
||||
run: |
|
||||
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
|
||||
if [ "$E2E_MODE" = "skip" ]; then
|
||||
echo "No live CLI E2E needed: $E2E_REASON"
|
||||
exit 0
|
||||
fi
|
||||
packages="$E2E_LIVE_PACKAGES"
|
||||
if [ -z "$packages" ]; then
|
||||
echo "No CLI E2E packages to test after exclusions."
|
||||
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
|
||||
exit 1
|
||||
fi
|
||||
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
|
||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||
echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"
|
||||
echo "Live CLI E2E packages: $packages"
|
||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||
- name: Publish CLI E2E test report
|
||||
if: ${{ !cancelled() }}
|
||||
if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||
with:
|
||||
name: CLI E2E Tests
|
||||
|
||||
28
CHANGELOG.md
28
CHANGELOG.md
@@ -2,6 +2,33 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.66] - 2026-07-07
|
||||
|
||||
### Features
|
||||
|
||||
- support semantic recurring calendar operations (#1723)
|
||||
- minute wait (#1768)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- guide drive import concurrency conflicts (#1751)
|
||||
- **calendar**: guide approval room booking fallback (#1637)
|
||||
- support pnpm global installs in self-update (#1705)
|
||||
- resolve schema against runtime metadata in plugin builds; gate cache overlay by version (#1764)
|
||||
|
||||
### Documentation
|
||||
|
||||
- tighten doc creation validation workflow (#1759)
|
||||
- clarify success envelope contract — judge success by ok, not code (#1730)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **envvars**: consolidate agent env value access (#1757)
|
||||
|
||||
### Misc
|
||||
|
||||
- Improve agent-facing error guidance for drive, markdown, and wiki (#1779)
|
||||
|
||||
## [v1.0.65] - 2026-07-03
|
||||
|
||||
### Features
|
||||
@@ -1371,6 +1398,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66
|
||||
[v1.0.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65
|
||||
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
|
||||
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
|
||||
|
||||
2
Makefile
2
Makefile
@@ -51,7 +51,7 @@ script-test:
|
||||
bash scripts/resolve-changed-from.test.sh
|
||||
bash scripts/ci-workflow.test.sh
|
||||
bash scripts/semantic-review-workflow.test.sh
|
||||
$(NODE) --test scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
$(NODE) --test scripts/e2e_domains.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
|
||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||
unit-test: fetch_meta
|
||||
|
||||
@@ -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"}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -10,20 +10,22 @@ import "github.com/larksuite/cli/errs"
|
||||
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||
var driveCodeMeta = map[int]CodeMeta{
|
||||
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
|
||||
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
|
||||
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
|
||||
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
|
||||
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
||||
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
|
||||
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
|
||||
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
|
||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
|
||||
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
|
||||
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
|
||||
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
|
||||
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
|
||||
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
|
||||
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
|
||||
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||
1061101: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file quota exceeded
|
||||
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
||||
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
|
||||
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
|
||||
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
|
||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
|
||||
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
|
||||
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
|
||||
233523001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive/docs transient server error
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(driveCodeMeta, "drive") }
|
||||
|
||||
@@ -114,8 +114,35 @@ func TestLookupCodeMeta_DrivePushCodes(t *testing.T) {
|
||||
{1061004, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
||||
{1061007, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
||||
{1061043, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
||||
{1061101, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
||||
{1062009, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{2200, errs.CategoryAPI, errs.SubtypeServerError, true},
|
||||
{233523001, errs.CategoryAPI, errs.SubtypeServerError, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(tc.code)
|
||||
if !ok {
|
||||
t.Fatalf("LookupCodeMeta(%d) ok=false, want true", tc.code)
|
||||
}
|
||||
if got.Category != tc.wantCat || got.Subtype != tc.wantSubtype || got.Retryable != tc.wantRetry {
|
||||
t.Fatalf("LookupCodeMeta(%d) = %+v, want Category=%v Subtype=%v Retryable=%v",
|
||||
tc.code, got, tc.wantCat, tc.wantSubtype, tc.wantRetry)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_WikiCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
code int
|
||||
wantCat errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantRetry bool
|
||||
}{
|
||||
{131002, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{131005, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
||||
{131006, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||
|
||||
17
internal/errclass/codemeta_wiki.go
Normal file
17
internal/errclass/codemeta_wiki.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// wikiCodeMeta holds wiki-service Lark code -> CodeMeta mappings observed from
|
||||
// wiki shortcut failure telemetry. Keep these to wiki-wide meanings only; add
|
||||
// command-specific recovery guidance at the shortcut layer.
|
||||
var wikiCodeMeta = map[int]CodeMeta{
|
||||
131002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // param err: space_id is not int / invalid page_token
|
||||
131005: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // wiki node / space not found
|
||||
131006: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // wiki space/node read permission denied
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(wikiCodeMeta, "wiki") }
|
||||
@@ -248,10 +248,18 @@ func TestLoadPlatformAutoApproveSet(t *testing.T) {
|
||||
|
||||
func TestLoadOverrideAutoApproveAllow(t *testing.T) {
|
||||
allowSet := LoadOverrideAutoApproveAllow()
|
||||
// recommend.allow in scope_overrides.json is intentionally empty:
|
||||
// no scopes are special-cased into the auto-approve set anymore.
|
||||
if len(allowSet) != 0 {
|
||||
t.Errorf("expected empty override allow set, got %d entries", len(allowSet))
|
||||
// recommend.allow special-cases scopes absent from scope_priorities.json
|
||||
// (application v7 is not in the platform catalog yet) so interactive
|
||||
// login's "common scopes" tier still offers them. Only the read scope is
|
||||
// admitted: write stays out of the recommended tier by design.
|
||||
if !allowSet["application:app_slash_command:read"] {
|
||||
t.Error("expected application:app_slash_command:read in override allow set")
|
||||
}
|
||||
if allowSet["application:app_slash_command:write"] {
|
||||
t.Error("write scope must NOT be in the recommended tier")
|
||||
}
|
||||
if len(allowSet) != 1 {
|
||||
t.Errorf("expected exactly 1 override allow entry, got %d", len(allowSet))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
"vc:meeting.meetingevent:read": 75
|
||||
},
|
||||
"recommend": {
|
||||
"allow": [],
|
||||
"allow": [
|
||||
"application:app_slash_command:read"
|
||||
],
|
||||
"deny": [
|
||||
"im:chat",
|
||||
"im:message.send_as_user"
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"en": { "title": "Approval", "description": "Approval instance, and task management" },
|
||||
"zh": { "title": "审批", "description": "审批实例、审批任务管理" }
|
||||
},
|
||||
"application": {
|
||||
"en": { "title": "Application", "description": "Open Platform app self-management: slash commands 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 页面和应用" }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.65",
|
||||
"version": "1.0.66",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -215,6 +215,73 @@ if ! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$section"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$dry_run_section" ||
|
||||
! grep -Fq "id: e2e_domains" <<<"$dry_run_section" ||
|
||||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should resolve changed-file CLI E2E domains before running tests"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "steps.e2e_domains.outputs.dry_packages" <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should use resolved dry_packages instead of always running the full suite"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$dry_run_section" ||
|
||||
! grep -Fq 'echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should pass dynamic domain output through env before shell use"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "E2E_DRY_ROOT_PACKAGE: \${{ steps.e2e_domains.outputs.dry_root_package }}" <<<"$dry_run_section" ||
|
||||
! grep -Fq 'go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"' <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should run the root CLI E2E harness package without the DryRun/Regression filter"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "No dry-run CLI E2E needed" <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should explicitly skip when domain mode is skip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$section" ||
|
||||
! grep -Fq "id: e2e_domains" <<<"$section" ||
|
||||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$section"; then
|
||||
echo "e2e-live should resolve changed-file CLI E2E domains before credentials and tests"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "steps.e2e_domains.outputs.live_packages" <<<"$section"; then
|
||||
echo "e2e-live should use resolved live_packages instead of always running the full suite"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$section" ||
|
||||
! grep -Fq 'echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$section"; then
|
||||
echo "e2e-live should pass dynamic domain output through env before shell use"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! awk '
|
||||
/^ - name: Build lark-cli/ { in_step = 1 }
|
||||
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
|
||||
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
|
||||
END { exit found ? 0 : 1 }
|
||||
' <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should skip building lark-cli when domain mode is skip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! awk '
|
||||
/^ - name: Build lark-cli/ { in_step = 1 }
|
||||
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
|
||||
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
|
||||
END { exit found ? 0 : 1 }
|
||||
' <<<"$section"; then
|
||||
echo "e2e-live should skip building lark-cli when domain mode is skip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "permissions:" <<<"$section" ||
|
||||
! grep -Fq "contents: read" <<<"$section" ||
|
||||
! grep -Fq "checks: write" <<<"$section"; then
|
||||
@@ -237,13 +304,23 @@ if ! grep -Fq "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_A
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! awk '
|
||||
/^ - name: Configure bot credentials/ { in_step = 1 }
|
||||
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
|
||||
in_step && /^ - name:/ && !/Configure bot credentials/ { in_step = 0 }
|
||||
END { exit found ? 0 : 1 }
|
||||
' <<<"$section"; then
|
||||
echo "e2e-live should only configure bot credentials when domain mode is not skip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -Fq "steps.live_e2e_credentials.outputs.configured" <<<"$section"; then
|
||||
echo "e2e-live build, configure, test, and report steps should not be gated by a skip-state output"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "if: \${{ !cancelled() }}" <<<"$section"; then
|
||||
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled"
|
||||
if ! grep -Fq "if: \${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}" <<<"$section"; then
|
||||
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled or domain mode is skip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
54
scripts/domain-map.js
Normal file
54
scripts/domain-map.js
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const DOMAIN_MAP_PATH = path.join(__dirname, "domain-map.json");
|
||||
const domainMap = JSON.parse(fs.readFileSync(DOMAIN_MAP_PATH, "utf8"));
|
||||
|
||||
function normalizeRepoPath(input) {
|
||||
return String(input || "").trim().replace(/\\/g, "/").replace(/^\.\//, "").toLowerCase();
|
||||
}
|
||||
|
||||
const pathMappingsBySpecificity = (domainMap.pathMappings || [])
|
||||
.map((entry) => ({ ...entry, prefix: normalizeRepoPath(entry.prefix) }))
|
||||
.sort((a, b) => b.prefix.length - a.prefix.length);
|
||||
|
||||
function findPathMapping(filePath) {
|
||||
const normalized = normalizeRepoPath(filePath);
|
||||
return pathMappingsBySpecificity.find((entry) => normalized.startsWith(entry.prefix));
|
||||
}
|
||||
|
||||
function labelDomainsForPath(filePath) {
|
||||
const mapping = findPathMapping(filePath);
|
||||
return mapping ? [...(mapping.labelDomains || [])] : [];
|
||||
}
|
||||
|
||||
function e2eDomainsForPath(filePath) {
|
||||
const mapping = findPathMapping(filePath);
|
||||
return mapping ? [...(mapping.e2eDomains || [])] : [];
|
||||
}
|
||||
|
||||
function matchesFullFallback(filePath) {
|
||||
const normalized = normalizeRepoPath(filePath);
|
||||
return (domainMap.fullFallbackPrefixes || []).some((prefix) => normalized.startsWith(prefix));
|
||||
}
|
||||
|
||||
function isSkippablePath(filePath) {
|
||||
const normalized = normalizeRepoPath(filePath);
|
||||
const basename = path.posix.basename(normalized);
|
||||
return (domainMap.skipPrefixes || []).some((prefix) => normalized.startsWith(prefix))
|
||||
|| (domainMap.skipSuffixes || []).some((suffix) => normalized.endsWith(suffix))
|
||||
|| (domainMap.skipFilenames || []).includes(basename);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
domainMap,
|
||||
e2eDomainsForPath,
|
||||
findPathMapping,
|
||||
isSkippablePath,
|
||||
labelDomainsForPath,
|
||||
matchesFullFallback,
|
||||
normalizeRepoPath,
|
||||
};
|
||||
71
scripts/domain-map.json
Normal file
71
scripts/domain-map.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"pathMappings": [
|
||||
{ "prefix": "shortcuts/im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
|
||||
{ "prefix": "shortcuts/vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
|
||||
{ "prefix": "shortcuts/calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
|
||||
{ "prefix": "shortcuts/doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
|
||||
{ "prefix": "shortcuts/sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
|
||||
{ "prefix": "shortcuts/drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
|
||||
{ "prefix": "shortcuts/wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
|
||||
{ "prefix": "shortcuts/base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
|
||||
{ "prefix": "shortcuts/mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
|
||||
{ "prefix": "shortcuts/task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
|
||||
{ "prefix": "shortcuts/contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
|
||||
{ "prefix": "shortcuts/apps/", "labelDomains": [], "e2eDomains": ["apps"] },
|
||||
{ "prefix": "shortcuts/markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
|
||||
{ "prefix": "shortcuts/minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
|
||||
{ "prefix": "shortcuts/okr/", "labelDomains": [], "e2eDomains": ["okr"] },
|
||||
{ "prefix": "shortcuts/slides/", "labelDomains": [], "e2eDomains": ["slides"] },
|
||||
{ "prefix": "shortcuts/note/", "labelDomains": [], "e2eDomains": ["note"] },
|
||||
{ "prefix": "shortcuts/event/", "labelDomains": [], "e2eDomains": ["event"] },
|
||||
|
||||
{ "prefix": "skills/lark-im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
|
||||
{ "prefix": "skills/lark-vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
|
||||
{ "prefix": "skills/lark-doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
|
||||
{ "prefix": "skills/lark-wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
|
||||
{ "prefix": "skills/lark-drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
|
||||
{ "prefix": "skills/lark-sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
|
||||
{ "prefix": "skills/lark-base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
|
||||
{ "prefix": "skills/lark-mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
|
||||
{ "prefix": "skills/lark-calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
|
||||
{ "prefix": "skills/lark-task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
|
||||
{ "prefix": "skills/lark-contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
|
||||
{ "prefix": "skills/lark-apps/", "labelDomains": [], "e2eDomains": ["apps"] },
|
||||
{ "prefix": "skills/lark-markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
|
||||
{ "prefix": "skills/lark-minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
|
||||
{ "prefix": "skills/lark-okr/", "labelDomains": [], "e2eDomains": ["okr"] },
|
||||
{ "prefix": "skills/lark-slides/", "labelDomains": [], "e2eDomains": ["slides"] },
|
||||
{ "prefix": "skills/lark-note/", "labelDomains": [], "e2eDomains": ["note"] },
|
||||
{ "prefix": "skills/lark-event/", "labelDomains": [], "e2eDomains": ["event"] }
|
||||
],
|
||||
"fullFallbackPrefixes": [
|
||||
"shortcuts/common/",
|
||||
"cmd/",
|
||||
"internal/",
|
||||
"pkg/",
|
||||
"extension/",
|
||||
"registry/",
|
||||
"go.mod",
|
||||
"go.sum",
|
||||
"Makefile",
|
||||
".github/workflows/",
|
||||
"scripts/"
|
||||
],
|
||||
"skipPrefixes": [
|
||||
"docs/",
|
||||
".changeset/"
|
||||
],
|
||||
"skipSuffixes": [
|
||||
".md",
|
||||
".mdx",
|
||||
".txt",
|
||||
".rst"
|
||||
],
|
||||
"skipFilenames": [
|
||||
"readme.md",
|
||||
"readme.zh.md",
|
||||
"changelog.md",
|
||||
"license",
|
||||
"cla.md"
|
||||
]
|
||||
}
|
||||
224
scripts/e2e_domains.js
Normal file
224
scripts/e2e_domains.js
Normal file
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env node
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const {
|
||||
e2eDomainsForPath,
|
||||
findPathMapping,
|
||||
isSkippablePath,
|
||||
matchesFullFallback,
|
||||
normalizeRepoPath,
|
||||
} = require("./domain-map");
|
||||
|
||||
const ROOT = process.env.E2E_DOMAINS_ROOT || path.join(__dirname, "..");
|
||||
process.chdir(ROOT);
|
||||
|
||||
function execLines(command, args) {
|
||||
return execFileSync(command, args, { encoding: "utf8" })
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function modulePath() {
|
||||
return execLines("go", ["list", "-m"])[0];
|
||||
}
|
||||
|
||||
function rootPackage(moduleName) {
|
||||
return `${moduleName}/tests/cli_e2e`;
|
||||
}
|
||||
|
||||
function allLivePackages(moduleName) {
|
||||
return execLines("go", ["list", "./tests/cli_e2e/..."])
|
||||
.filter((pkg) => pkg !== rootPackage(moduleName))
|
||||
.filter((pkg) => !pkg.endsWith("/demo"));
|
||||
}
|
||||
|
||||
function allDryPackages(moduleName) {
|
||||
return allLivePackages(moduleName);
|
||||
}
|
||||
|
||||
const domainExistsCache = new Map();
|
||||
|
||||
function domainExists(domain) {
|
||||
if (domainExistsCache.has(domain)) {
|
||||
return domainExistsCache.get(domain);
|
||||
}
|
||||
let exists = false;
|
||||
try {
|
||||
execFileSync("go", ["list", `./tests/cli_e2e/${domain}`], { stdio: "ignore" });
|
||||
exists = true;
|
||||
} catch {
|
||||
exists = false;
|
||||
}
|
||||
domainExistsCache.set(domain, exists);
|
||||
return exists;
|
||||
}
|
||||
|
||||
function readChangedFiles() {
|
||||
const changedFilesPath = process.env.E2E_DOMAIN_CHANGED_FILES;
|
||||
if (changedFilesPath) {
|
||||
return fs.readFileSync(changedFilesPath, "utf8")
|
||||
.split(/\r?\n/)
|
||||
.map(normalizeRepoPath)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
if (process.env.GITHUB_EVENT_NAME !== "pull_request") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseRef = process.env.GITHUB_BASE_REF || "main";
|
||||
try {
|
||||
execFileSync("git", ["rev-parse", "--verify", `origin/${baseRef}`], { stdio: "ignore" });
|
||||
return execLines("git", ["diff", "--name-only", `origin/${baseRef}...HEAD`]).map(normalizeRepoPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function addDomain(domains, domain) {
|
||||
if (domain && domainExists(domain)) {
|
||||
domains.add(domain);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function classifyPath(filePath, domains) {
|
||||
const normalized = normalizeRepoPath(filePath);
|
||||
if (!normalized) return { matched: false };
|
||||
|
||||
const e2eMatch = normalized.match(/^tests\/cli_e2e\/([^/]+)\//);
|
||||
if (e2eMatch) {
|
||||
const domain = e2eMatch[1];
|
||||
if (domain === "demo") return { matched: false };
|
||||
if (domainExists(domain)) {
|
||||
addDomain(domains, domain);
|
||||
return { matched: true };
|
||||
}
|
||||
if (isSkippablePath(normalized)) return { matched: false };
|
||||
return { fullReason: `unknown CLI E2E domain path: ${normalized}` };
|
||||
}
|
||||
|
||||
if (normalized.startsWith("tests/cli_e2e/")) {
|
||||
return { fullReason: `shared CLI E2E harness changed: ${normalized}` };
|
||||
}
|
||||
|
||||
if (matchesFullFallback(normalized)) {
|
||||
return { fullReason: `shared/runtime path changed: ${normalized}` };
|
||||
}
|
||||
|
||||
const mappedDomains = e2eDomainsForPath(normalized);
|
||||
if (mappedDomains.length > 0) {
|
||||
const missingDomains = [];
|
||||
for (const domain of mappedDomains) {
|
||||
if (!addDomain(domains, domain)) missingDomains.push(domain);
|
||||
}
|
||||
if (missingDomains.length > 0) {
|
||||
return { fullReason: `mapped CLI E2E domain has no package: ${missingDomains.join(",")} (${normalized})` };
|
||||
}
|
||||
return { matched: true };
|
||||
}
|
||||
|
||||
if (findPathMapping(normalized)) {
|
||||
return { fullReason: `mapped path has no CLI E2E package: ${normalized}` };
|
||||
}
|
||||
|
||||
if (normalized.match(/^shortcuts\/[^/]+\//) || normalized.match(/^skills\/lark-[^/]+\//)) {
|
||||
return { fullReason: `unmapped CLI E2E domain path: ${normalized}` };
|
||||
}
|
||||
|
||||
if (isSkippablePath(normalized)) return { matched: false };
|
||||
|
||||
return { fullReason: `unclassified path changed: ${normalized}` };
|
||||
}
|
||||
|
||||
function resolveDomains(changedFiles) {
|
||||
const moduleName = modulePath();
|
||||
const rootDryPackage = rootPackage(moduleName);
|
||||
if (changedFiles === null) {
|
||||
return {
|
||||
mode: "full",
|
||||
reason: "non-pull_request run or unavailable diff",
|
||||
domains: ["all"],
|
||||
dryRootPackage: rootDryPackage,
|
||||
dryPackages: allDryPackages(moduleName),
|
||||
livePackages: allLivePackages(moduleName),
|
||||
};
|
||||
}
|
||||
|
||||
const domains = new Set();
|
||||
let matchedRelevant = false;
|
||||
let fullReason = "";
|
||||
|
||||
for (const file of changedFiles) {
|
||||
const result = classifyPath(file, domains);
|
||||
if (result.matched) matchedRelevant = true;
|
||||
if (result.fullReason && !fullReason) fullReason = result.fullReason;
|
||||
}
|
||||
|
||||
if (fullReason) {
|
||||
return {
|
||||
mode: "full",
|
||||
reason: fullReason,
|
||||
domains: ["all"],
|
||||
dryRootPackage: rootDryPackage,
|
||||
dryPackages: allDryPackages(moduleName),
|
||||
livePackages: allLivePackages(moduleName),
|
||||
};
|
||||
}
|
||||
|
||||
if (matchedRelevant && domains.size > 0) {
|
||||
const sortedDomains = [...domains].sort();
|
||||
const packages = sortedDomains.map((domain) => `${moduleName}/tests/cli_e2e/${domain}`);
|
||||
return {
|
||||
mode: "subset",
|
||||
reason: "business domain changes",
|
||||
domains: sortedDomains,
|
||||
dryRootPackage: rootDryPackage,
|
||||
dryPackages: packages,
|
||||
livePackages: packages,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "skip",
|
||||
reason: "docs-only or no live CLI E2E impact",
|
||||
domains: [],
|
||||
dryRootPackage: "",
|
||||
dryPackages: [],
|
||||
livePackages: [],
|
||||
};
|
||||
}
|
||||
|
||||
function emit(resolved) {
|
||||
const values = {
|
||||
mode: resolved.mode,
|
||||
reason: resolved.reason,
|
||||
domains: resolved.domains.join(","),
|
||||
dry_root_package: resolved.dryRootPackage,
|
||||
dry_packages: resolved.dryPackages.join(" "),
|
||||
live_packages: resolved.livePackages.join(" "),
|
||||
};
|
||||
|
||||
const lines = Object.entries(values).map(([key, value]) => `${key}=${value}`);
|
||||
console.log(lines.join("\n"));
|
||||
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join("\n")}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
emit(resolveDomains(readChangedFiles()));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
classifyPath,
|
||||
readChangedFiles,
|
||||
resolveDomains,
|
||||
};
|
||||
94
scripts/e2e_domains.test.js
Normal file
94
scripts/e2e_domains.test.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const test = require("node:test");
|
||||
|
||||
const scriptPath = path.join(__dirname, "e2e_domains.js");
|
||||
|
||||
function parseOutput(raw) {
|
||||
const result = {};
|
||||
for (const line of raw.trim().split(/\r?\n/)) {
|
||||
const idx = line.indexOf("=");
|
||||
if (idx === -1) continue;
|
||||
result[line.slice(0, idx)] = line.slice(idx + 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function runDomains(files) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-domains-"));
|
||||
const file = path.join(dir, "changed.txt");
|
||||
fs.writeFileSync(file, `${files.join("\n")}\n`);
|
||||
try {
|
||||
return parseOutput(execFileSync(process.execPath, [scriptPath], {
|
||||
cwd: path.join(__dirname, ".."),
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, E2E_DOMAIN_CHANGED_FILES: file },
|
||||
}));
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("maps shortcut changes to one business domain package", () => {
|
||||
const output = runDomains(["shortcuts/im/messages/send.go"]);
|
||||
assert.equal(output.mode, "subset");
|
||||
assert.equal(output.domains, "im");
|
||||
assert.match(output.dry_root_package, /github\.com\/larksuite\/cli\/tests\/cli_e2e$/);
|
||||
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/im/);
|
||||
assert.doesNotMatch(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
|
||||
});
|
||||
|
||||
test("maps doc shortcuts to docs package", () => {
|
||||
const output = runDomains(["shortcuts/doc/update.go"]);
|
||||
assert.equal(output.mode, "subset");
|
||||
assert.equal(output.domains, "docs");
|
||||
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/docs/);
|
||||
});
|
||||
|
||||
test("maps direct e2e domain package changes", () => {
|
||||
const output = runDomains(["tests/cli_e2e/drive/helpers.go"]);
|
||||
assert.equal(output.mode, "subset");
|
||||
assert.equal(output.domains, "drive");
|
||||
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
|
||||
});
|
||||
|
||||
test("falls back to full for shared e2e harness changes", () => {
|
||||
const output = runDomains(["tests/cli_e2e/core.go"]);
|
||||
assert.equal(output.mode, "full");
|
||||
assert.equal(output.domains, "all");
|
||||
assert.match(output.reason, /shared CLI E2E harness changed/);
|
||||
});
|
||||
|
||||
test("falls back to full for runtime changes", () => {
|
||||
const output = runDomains(["cmd/root.go"]);
|
||||
assert.equal(output.mode, "full");
|
||||
assert.equal(output.domains, "all");
|
||||
assert.match(output.reason, /shared\/runtime path changed/);
|
||||
});
|
||||
|
||||
test("skips docs-only changes", () => {
|
||||
const output = runDomains(["docs/usage.md", "README.md"]);
|
||||
assert.equal(output.mode, "skip");
|
||||
assert.equal(output.domains, "");
|
||||
assert.equal(output.dry_root_package, "");
|
||||
assert.equal(output.live_packages, "");
|
||||
});
|
||||
|
||||
test("uses shared map for skill domain changes", () => {
|
||||
const output = runDomains(["skills/lark-sheets/SKILL.md"]);
|
||||
assert.equal(output.mode, "subset");
|
||||
assert.equal(output.domains, "sheets");
|
||||
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/sheets/);
|
||||
});
|
||||
|
||||
test("falls back to full when a mapped path has no e2e package", () => {
|
||||
const output = runDomains(["shortcuts/whiteboard/export.go"]);
|
||||
assert.equal(output.mode, "full");
|
||||
assert.match(output.reason, /unmapped CLI E2E domain path/);
|
||||
});
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { labelDomainsForPath } = require("../domain-map");
|
||||
|
||||
// ============================================================================
|
||||
// Constants & Configuration
|
||||
@@ -35,33 +36,6 @@ const CORE_PREFIXES = ["internal/auth/", "internal/engine/", "internal/config/",
|
||||
const HEAD_BUSINESS_DOMAINS = new Set(["im", "contact", "ccm", "base", "docx"]);
|
||||
const LOW_RISK_TYPES = new Set(["docs", "ci", "test", "chore"]);
|
||||
|
||||
// CODEOWNERS-based path to domain label mapping
|
||||
// Maps shortcuts and skills paths to business domain labels
|
||||
const PATH_TO_DOMAIN_MAP = {
|
||||
// shortcuts
|
||||
"shortcuts/im/": "im",
|
||||
"shortcuts/vc/": "vc",
|
||||
"shortcuts/calendar/": "calendar",
|
||||
"shortcuts/doc/": "ccm",
|
||||
"shortcuts/sheets/": "ccm",
|
||||
"shortcuts/drive/": "ccm",
|
||||
"shortcuts/wiki/": "ccm",
|
||||
"shortcuts/base/": "base",
|
||||
"shortcuts/mail/": "mail",
|
||||
"shortcuts/task/": "task",
|
||||
"shortcuts/contact/": "contact",
|
||||
// skills
|
||||
"skills/lark-im/": "im",
|
||||
"skills/lark-vc/": "vc",
|
||||
"skills/lark-doc/": "ccm",
|
||||
"skills/lark-wiki/": "ccm",
|
||||
"skills/lark-base/": "base",
|
||||
"skills/lark-mail/": "mail",
|
||||
"skills/lark-calendar/": "calendar",
|
||||
"skills/lark-task/": "task",
|
||||
"skills/lark-contact/": "contact",
|
||||
};
|
||||
|
||||
const SENSITIVE_PATTERN = /(^|\/)(auth|permission|permissions|security)(\/|_|\.|$)/;
|
||||
|
||||
const CLASS_STANDARDS = {
|
||||
@@ -285,13 +259,7 @@ function skillDomainForPath(filePath) {
|
||||
|
||||
// Get business domain label based on CODEOWNERS path mapping
|
||||
function getBusinessDomain(filePath) {
|
||||
const normalized = normalizePath(filePath);
|
||||
for (const [prefix, domain] of Object.entries(PATH_TO_DOMAIN_MAP)) {
|
||||
if (normalized.startsWith(prefix)) {
|
||||
return domain;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
return labelDomainsForPath(filePath)[0] || "";
|
||||
}
|
||||
|
||||
async function detectNewShortcutDomain(files) {
|
||||
|
||||
@@ -8,7 +8,17 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
script="$repo_root/scripts/resolve-changed-from.sh"
|
||||
|
||||
tmp="${TMPDIR:-/tmp}/resolve-changed-from-test-$$"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
cleanup_tmp() {
|
||||
local attempt
|
||||
for attempt in 1 2 3; do
|
||||
rm -rf "$tmp" && return 0
|
||||
sleep 1
|
||||
done
|
||||
rm -rf "$tmp"
|
||||
}
|
||||
|
||||
trap cleanup_tmp EXIT
|
||||
mkdir -p "$tmp"
|
||||
|
||||
git_init() {
|
||||
|
||||
18
shortcuts/application/shortcuts.go
Normal file
18
shortcuts/application/shortcuts.go
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package application provides shortcuts for Open Platform app
|
||||
// self-management (slash commands of the current bound app).
|
||||
package application
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
// Shortcuts returns all shortcuts of the application domain.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
SlashCommandList,
|
||||
SlashCommandCreate,
|
||||
SlashCommandUpdate,
|
||||
SlashCommandDelete,
|
||||
}
|
||||
}
|
||||
98
shortcuts/application/slash_command_common.go
Normal file
98
shortcuts/application/slash_command_common.go
Normal 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")
|
||||
}
|
||||
155
shortcuts/application/slash_command_common_test.go
Normal file
155
shortcuts/application/slash_command_common_test.go
Normal 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
|
||||
}
|
||||
117
shortcuts/application/slash_command_create.go
Normal file
117
shortcuts/application/slash_command_create.go
Normal 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
|
||||
},
|
||||
}
|
||||
182
shortcuts/application/slash_command_create_test.go
Normal file
182
shortcuts/application/slash_command_create_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
83
shortcuts/application/slash_command_delete.go
Normal file
83
shortcuts/application/slash_command_delete.go
Normal 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
|
||||
},
|
||||
}
|
||||
109
shortcuts/application/slash_command_delete_test.go
Normal file
109
shortcuts/application/slash_command_delete_test.go
Normal 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-level,append 后设置 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
58
shortcuts/application/slash_command_list.go
Normal file
58
shortcuts/application/slash_command_list.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlashCommandList lists all slash commands of the current bound app.
|
||||
var SlashCommandList = common.Shortcut{
|
||||
Service: "application",
|
||||
Command: "+slash-command-list",
|
||||
Description: "List all slash commands (/ commands) registered on the 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
|
||||
},
|
||||
}
|
||||
115
shortcuts/application/slash_command_list_test.go
Normal file
115
shortcuts/application/slash_command_list_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func appTestConfig() *core.CliConfig {
|
||||
return &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
|
||||
}
|
||||
|
||||
// mountAndRun mounts the shortcut under a parent cobra command and runs it.
|
||||
// Mirrors shortcuts/contact tests.
|
||||
func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "application"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
func listStub(items []interface{}) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/application/v7/app_slash_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"items": items},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func sampleItem(name, id string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"command": name, "command_id": id,
|
||||
"create_time": "1783318553", "update_time": "1783318553",
|
||||
"description": map[string]interface{}{"default_value": "desc of " + name},
|
||||
"icon": map[string]interface{}{"icon_key": "skill_outlined"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandList_JSON(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(listStub([]interface{}{sampleItem("greet", "id1"), sampleItem("weather", "id2")}))
|
||||
|
||||
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--format", "json", "--as", "bot"}, f, stdout); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
items := data["items"].([]interface{})
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("items = %d", len(items))
|
||||
}
|
||||
if data["count"] != float64(2) {
|
||||
t.Fatalf("count = %v", data["count"])
|
||||
}
|
||||
first := items[0].(map[string]interface{})
|
||||
for _, k := range []string{"command", "command_id", "description", "icon", "create_time", "update_time"} {
|
||||
if _, ok := first[k]; !ok {
|
||||
t.Errorf("missing item key %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandList_Empty(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(listStub(nil))
|
||||
|
||||
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--format", "json", "--as", "bot"}, f, stdout); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
items, ok := data["items"].([]interface{})
|
||||
if !ok || len(items) != 0 {
|
||||
t.Fatalf("empty list must be [] not %v", data["items"])
|
||||
}
|
||||
if data["count"] != float64(0) {
|
||||
t.Fatalf("count = %v", data["count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandList_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--dry-run", "--as", "bot"}, f, stdout); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "/open-apis/application/v7/app_slash_commands") || !strings.Contains(out, "GET") {
|
||||
t.Fatalf("dry-run must show GET path, got %s", out)
|
||||
}
|
||||
}
|
||||
53
shortcuts/application/slash_command_resolve.go
Normal file
53
shortcuts/application/slash_command_resolve.go
Normal 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
|
||||
}
|
||||
39
shortcuts/application/slash_command_resolve_test.go
Normal file
39
shortcuts/application/slash_command_resolve_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
119
shortcuts/application/slash_command_update.go
Normal file
119
shortcuts/application/slash_command_update.go
Normal 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
|
||||
},
|
||||
}
|
||||
79
shortcuts/application/slash_command_update_test.go
Normal file
79
shortcuts/application/slash_command_update_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func wrapExportContextErr(err error) error {
|
||||
var DriveExport = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+export",
|
||||
Description: "Export a doc/docx/sheet/bitable/slides or wiki document to a local file with limited polling",
|
||||
Description: "Export a doc/docx/sheet/bitable/slides to a local file with limited polling",
|
||||
Risk: "read",
|
||||
Scopes: []string{
|
||||
"docs:document.content:read",
|
||||
@@ -47,12 +47,10 @@ var DriveExport = common.Shortcut{
|
||||
"docx:document:readonly",
|
||||
"drive:drive.metadata:readonly",
|
||||
},
|
||||
ConditionalScopes: []string{"wiki:node:retrieve"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "source document URL; doc type and token are inferred, and wiki URLs are resolved to the underlying document"},
|
||||
{Name: "token", Desc: "source document token; bare tokens require --doc-type, while wiki tokens can use --doc-type wiki or fallback after file-token-invalid"},
|
||||
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable | slides | wiki (required only when --token is a bare token)", Enum: []string{"doc", "docx", "sheet", "bitable", "slides", "wiki"}},
|
||||
{Name: "token", Desc: "source document token", Required: true},
|
||||
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable | slides", Required: true, Enum: []string{"doc", "docx", "sheet", "bitable", "slides"}},
|
||||
{Name: "file-extension", Desc: "export format: docx | pdf | xlsx | csv | markdown | base (bitable only) | pptx (slides only)", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx"}},
|
||||
{Name: "sub-id", Desc: "sub-table/sheet ID, required when exporting sheet/bitable as csv"},
|
||||
{Name: "only-schema", Type: "bool", Desc: "export only bitable schema when --doc-type bitable --file-extension base"},
|
||||
@@ -77,7 +75,6 @@ var DriveExport = common.Shortcut{
|
||||
// task and poll, but do not download" — callers that only need the ready file
|
||||
// token / status get it back without writing a local file.
|
||||
type ExportParams struct {
|
||||
URL string
|
||||
Token string
|
||||
DocType string
|
||||
FileExtension string
|
||||
@@ -90,7 +87,6 @@ type ExportParams struct {
|
||||
|
||||
func (p ExportParams) spec() driveExportSpec {
|
||||
return driveExportSpec{
|
||||
URL: p.URL,
|
||||
Token: p.Token,
|
||||
DocType: p.DocType,
|
||||
FileExtension: p.FileExtension,
|
||||
@@ -110,7 +106,6 @@ func exportParamsFromFlags(runtime *common.RuntimeContext) ExportParams {
|
||||
outputDir = "."
|
||||
}
|
||||
return ExportParams{
|
||||
URL: runtime.Str("url"),
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
@@ -132,93 +127,60 @@ func validateExport(p ExportParams) error {
|
||||
|
||||
// PlanExportDryRun builds the dry-run plan for an export without performing I/O.
|
||||
func PlanExportDryRun(runtime *common.RuntimeContext, p ExportParams) *common.DryRunAPI {
|
||||
spec, source, err := normalizeDriveExportSpecInput(p.spec())
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if err := validateDriveExportNormalizedSpecForSource(spec, source); err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
if source.Type == "wiki" {
|
||||
dry.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[0] Resolve wiki node to underlying document token").
|
||||
Params(map[string]interface{}{"token": source.Token})
|
||||
spec.Token = "obj_token_from_step_0"
|
||||
if spec.DocType == "" {
|
||||
spec.DocType = "obj_type_from_step_0"
|
||||
}
|
||||
dry.Set("wiki_token", source.Token)
|
||||
}
|
||||
|
||||
spec := p.spec()
|
||||
// Markdown export is a special case: docx markdown comes from the V2
|
||||
// docs_ai fetch API directly instead of the Drive export task API.
|
||||
if spec.FileExtension == "markdown" {
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
desc := "2-step orchestration: fetch docx markdown -> write local file"
|
||||
if source.Type == "wiki" {
|
||||
desc = "3-step orchestration: resolve wiki -> fetch docx markdown -> write local file"
|
||||
}
|
||||
dry.Desc(desc).
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: fetch docx markdown -> write local file").
|
||||
POST(apiPath).
|
||||
Body(map[string]interface{}{
|
||||
"format": "markdown",
|
||||
}).
|
||||
Set("output_dir", p.OutputDir)
|
||||
if name := strings.TrimSpace(p.FileName); name != "" {
|
||||
dry.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dry
|
||||
return dr
|
||||
}
|
||||
|
||||
desc := "3-step orchestration: create export task -> limited polling -> download file"
|
||||
if source.Type == "wiki" {
|
||||
desc = "4-step orchestration: resolve wiki -> create export task -> limited polling -> download file"
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
}
|
||||
dry.Desc(desc).
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
if spec.OnlySchema {
|
||||
body["only_schema"] = true
|
||||
}
|
||||
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("3-step orchestration: create export task -> limited polling -> download file").
|
||||
POST("/open-apis/drive/v1/export_tasks").
|
||||
Body(buildDriveExportTaskBody(spec)).
|
||||
Body(body).
|
||||
Set("output_dir", p.OutputDir)
|
||||
if name := strings.TrimSpace(p.FileName); name != "" {
|
||||
dry.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
if !source.WasURL {
|
||||
dry.Set("wiki_token_fallback", "if export task returns file token invalid, the CLI will resolve --token as a wiki node and retry once")
|
||||
}
|
||||
return dry
|
||||
return dr
|
||||
}
|
||||
|
||||
// RunExport drives create export task -> bounded poll -> optional download. It
|
||||
// is the shared core behind both drive +export and sheets +workbook-export. An
|
||||
// empty p.OutputDir skips the download step and returns the ready file token.
|
||||
func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportParams) error {
|
||||
spec, source, err := normalizeDriveExportSpecInput(p.spec())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDriveExportNormalizedSpecForSource(spec, source); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
spec := p.spec()
|
||||
outputDir := p.OutputDir
|
||||
preferredFileName := strings.TrimSpace(p.FileName)
|
||||
overwrite := p.Overwrite
|
||||
|
||||
var wikiResolution driveExportWikiResolution
|
||||
|
||||
// Markdown export bypasses the async export task and writes the fetched
|
||||
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
|
||||
// higher-quality Lark-flavored Markdown output.
|
||||
if spec.FileExtension == "markdown" {
|
||||
if source.Type == "wiki" {
|
||||
resolvedSpec, resolution, err := resolveDriveExportWikiSource(ctx, runtime, spec, source.Token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec = resolvedSpec
|
||||
wikiResolution = resolution
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
@@ -260,23 +222,21 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(annotateDriveExportWikiOutput(map[string]interface{}{
|
||||
runtime.Out(map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_name": filepath.Base(savedPath),
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": len(content),
|
||||
}, wikiResolution), nil)
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
ticket, resolvedSpec, resolution, err := createDriveExportTaskWithWikiFallback(ctx, runtime, spec, source)
|
||||
ticket, err := createDriveExportTask(runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec = resolvedSpec
|
||||
wikiResolution = resolution
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
|
||||
|
||||
var lastStatus driveExportStatus
|
||||
@@ -314,7 +274,7 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
|
||||
// no local download (e.g. sheets +workbook-export without an output
|
||||
// path). Skip the download and return the status envelope.
|
||||
if strings.TrimSpace(outputDir) == "" {
|
||||
runtime.Out(annotateDriveExportWikiOutput(map[string]interface{}{
|
||||
runtime.Out(map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
@@ -324,7 +284,7 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
|
||||
"file_size": status.FileSize,
|
||||
"ready": true,
|
||||
"downloaded": false,
|
||||
}, wikiResolution), nil)
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -347,7 +307,7 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
|
||||
out["ticket"] = ticket
|
||||
out["doc_type"] = spec.DocType
|
||||
out["file_extension"] = spec.FileExtension
|
||||
runtime.Out(annotateDriveExportWikiOutput(out, wikiResolution), nil)
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -397,19 +357,7 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
|
||||
if preferredFileName != "" {
|
||||
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
|
||||
}
|
||||
runtime.Out(annotateDriveExportWikiOutput(result, wikiResolution), nil)
|
||||
runtime.Out(result, nil)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
|
||||
return nil
|
||||
}
|
||||
|
||||
func annotateDriveExportWikiOutput(out map[string]interface{}, resolution driveExportWikiResolution) map[string]interface{} {
|
||||
if !resolution.Resolved {
|
||||
return out
|
||||
}
|
||||
out["wiki_token"] = resolution.WikiToken
|
||||
out["wiki_node"] = map[string]interface{}{
|
||||
"obj_token": resolution.ObjToken,
|
||||
"obj_type": resolution.ObjType,
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -27,16 +27,9 @@ var (
|
||||
driveExportPollInterval = 5 * time.Second
|
||||
)
|
||||
|
||||
const (
|
||||
driveExportResolvedDocTypeValues = "doc, docx, sheet, bitable, slides"
|
||||
driveExportInputDocTypeValues = driveExportResolvedDocTypeValues + ", wiki"
|
||||
driveExportFileExtensionValues = "docx, pdf, xlsx, csv, markdown, base, pptx"
|
||||
)
|
||||
|
||||
// driveExportSpec contains the normalized export request understood by the
|
||||
// shortcut and the underlying export task APIs.
|
||||
type driveExportSpec struct {
|
||||
URL string
|
||||
Token string
|
||||
DocType string
|
||||
FileExtension string
|
||||
@@ -44,20 +37,6 @@ type driveExportSpec struct {
|
||||
OnlySchema bool
|
||||
}
|
||||
|
||||
type driveExportInputSource struct {
|
||||
Type string
|
||||
Token string
|
||||
Param string
|
||||
WasURL bool
|
||||
}
|
||||
|
||||
type driveExportWikiResolution struct {
|
||||
Resolved bool
|
||||
WikiToken string
|
||||
ObjToken string
|
||||
ObjType string
|
||||
}
|
||||
|
||||
// driveExportTaskResultCommand prints the resume command shown when bounded
|
||||
// export polling times out locally.
|
||||
func driveExportTaskResultCommand(ticket, docToken string) string {
|
||||
@@ -148,49 +127,45 @@ func (s driveExportStatus) StatusLabel() string {
|
||||
// validateDriveExportSpec enforces shortcut-level export constraints before any
|
||||
// backend request is sent.
|
||||
func validateDriveExportSpec(spec driveExportSpec) error {
|
||||
normalized, source, err := normalizeDriveExportSpecInput(spec)
|
||||
if err != nil {
|
||||
return err
|
||||
if err := validate.ResourceName(spec.Token, "--token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
return validateDriveExportNormalizedSpecForSource(normalized, source)
|
||||
}
|
||||
|
||||
func validateDriveExportNormalizedSpec(spec driveExportSpec) error {
|
||||
switch spec.DocType {
|
||||
case "doc", "docx", "sheet", "bitable", "slides":
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc-type %q: allowed values are %s", spec.DocType, driveExportInputDocTypeValues).
|
||||
WithParam("--doc-type").
|
||||
WithHint("use --url when you have a document URL; use --doc-type wiki only with a bare Wiki node token so the CLI can resolve the underlying document type")
|
||||
}
|
||||
|
||||
if err := validate.ResourceName(spec.Token, "--token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc-type %q: allowed values are doc, docx, sheet, bitable, slides", spec.DocType).WithParam("--doc-type")
|
||||
}
|
||||
|
||||
switch spec.FileExtension {
|
||||
case "docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx":
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-extension %q: allowed values are %s", spec.FileExtension, driveExportFileExtensionValues).
|
||||
WithParam("--file-extension").
|
||||
WithHint("choose an export format supported by the source type; common choices are docx/pdf for docs, xlsx/csv for sheets, base for bitable, and pptx/pdf for slides")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-extension %q: allowed values are docx, pdf, xlsx, csv, markdown, base, pptx", spec.FileExtension).WithParam("--file-extension")
|
||||
}
|
||||
|
||||
if err := validateDriveExportFormatCompatibility(spec); err != nil {
|
||||
return err
|
||||
if spec.FileExtension == "markdown" && spec.DocType != "docx" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension markdown only supports --doc-type docx")
|
||||
}
|
||||
|
||||
if spec.FileExtension == "base" && spec.DocType != "bitable" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension base only supports --doc-type bitable")
|
||||
}
|
||||
|
||||
if spec.OnlySchema && (spec.DocType != "bitable" || spec.FileExtension != "base") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--only-schema is only used when exporting bitable as base").
|
||||
WithParam("--only-schema").
|
||||
WithHint("retry with --doc-type bitable --file-extension base, or remove --only-schema")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--only-schema is only used when exporting bitable as base").WithParam("--only-schema")
|
||||
}
|
||||
|
||||
if spec.FileExtension == "pptx" && spec.DocType != "slides" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension pptx only supports --doc-type slides")
|
||||
}
|
||||
|
||||
if spec.DocType == "slides" && spec.FileExtension != "pptx" && spec.FileExtension != "pdf" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc-type slides only supports --file-extension pptx or pdf")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
if spec.FileExtension != "csv" || (spec.DocType != "sheet" && spec.DocType != "bitable") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is only used when exporting sheet/bitable as csv").
|
||||
WithParam("--sub-id").
|
||||
WithHint("remove --sub-id, or retry with --doc-type sheet|bitable --file-extension csv")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is only used when exporting sheet/bitable as csv").WithParam("--sub-id")
|
||||
}
|
||||
if err := validate.ResourceName(spec.SubID, "--sub-id"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--sub-id")
|
||||
@@ -198,213 +173,15 @@ func validateDriveExportNormalizedSpec(spec driveExportSpec) error {
|
||||
}
|
||||
|
||||
if spec.FileExtension == "csv" && (spec.DocType == "sheet" || spec.DocType == "bitable") && strings.TrimSpace(spec.SubID) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is required when exporting sheet/bitable as csv").
|
||||
WithParam("--sub-id").
|
||||
WithHint("retry with --sub-id <sheet_id_or_table_id>; if you need the whole workbook, use --file-extension xlsx instead")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is required when exporting sheet/bitable as csv").WithParam("--sub-id")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDriveExportFormatCompatibility(spec driveExportSpec) error {
|
||||
if driveExportFileExtensionAllowedForDocType(spec.DocType, spec.FileExtension) {
|
||||
return nil
|
||||
}
|
||||
allowed := strings.Join(driveExportAllowedFileExtensions(spec.DocType), ", ")
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported export format: --doc-type %s cannot be exported as %s",
|
||||
spec.DocType,
|
||||
spec.FileExtension,
|
||||
).
|
||||
WithParam("--file-extension").
|
||||
WithHint("retry with --file-extension %s. If the token came from a URL, prefer --url so the CLI infers the correct source type before validating the export format", allowed)
|
||||
}
|
||||
|
||||
func driveExportFileExtensionAllowedForDocType(docType, fileExtension string) bool {
|
||||
for _, allowed := range driveExportAllowedFileExtensions(docType) {
|
||||
if fileExtension == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func driveExportAllowedFileExtensions(docType string) []string {
|
||||
switch normalizeDriveExportDocType(docType) {
|
||||
case "doc":
|
||||
return []string{"docx", "pdf"}
|
||||
case "docx":
|
||||
return []string{"docx", "pdf", "markdown"}
|
||||
case "sheet":
|
||||
return []string{"xlsx", "csv", "pdf"}
|
||||
case "bitable":
|
||||
return []string{"xlsx", "csv", "base", "pdf"}
|
||||
case "slides":
|
||||
return []string{"pptx", "pdf"}
|
||||
default:
|
||||
return []string{"docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx"}
|
||||
}
|
||||
}
|
||||
|
||||
func validateDriveExportNormalizedSpecForSource(spec driveExportSpec, source driveExportInputSource) error {
|
||||
if source.Type == "wiki" && spec.DocType == "" {
|
||||
return validateDriveExportPendingWikiSpec(spec, source)
|
||||
}
|
||||
return validateDriveExportNormalizedSpec(spec)
|
||||
}
|
||||
|
||||
func validateDriveExportPendingWikiSpec(spec driveExportSpec, source driveExportInputSource) error {
|
||||
param := source.Param
|
||||
if param == "" {
|
||||
param = "--token"
|
||||
}
|
||||
if err := validate.ResourceName(spec.Token, param); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(param)
|
||||
}
|
||||
|
||||
switch spec.FileExtension {
|
||||
case "docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx":
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-extension %q: allowed values are %s", spec.FileExtension, driveExportFileExtensionValues).
|
||||
WithParam("--file-extension").
|
||||
WithHint("Wiki export format is validated after resolving the Wiki node; choose a format normally supported by the underlying document type")
|
||||
}
|
||||
if spec.OnlySchema && spec.FileExtension != "base" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--only-schema is only used when exporting bitable as base").
|
||||
WithParam("--only-schema").
|
||||
WithHint("retry with --file-extension base, or remove --only-schema")
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" && spec.FileExtension != "csv" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is only used when exporting sheet/bitable as csv").
|
||||
WithParam("--sub-id").
|
||||
WithHint("remove --sub-id, or retry with --file-extension csv if the Wiki node resolves to a sheet/bitable")
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
if err := validate.ResourceName(spec.SubID, "--sub-id"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--sub-id")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeDriveExportSpecInput(spec driveExportSpec) (driveExportSpec, driveExportInputSource, error) {
|
||||
spec.URL = strings.TrimSpace(spec.URL)
|
||||
spec.Token = strings.TrimSpace(spec.Token)
|
||||
spec.DocType = strings.ToLower(strings.TrimSpace(spec.DocType))
|
||||
spec.FileExtension = strings.ToLower(strings.TrimSpace(spec.FileExtension))
|
||||
|
||||
if spec.Token == "" && spec.URL == "" {
|
||||
return spec, driveExportInputSource{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "either --url or --token is required").WithParam("--url")
|
||||
}
|
||||
if spec.Token != "" && spec.URL != "" {
|
||||
return spec, driveExportInputSource{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url and --token are mutually exclusive").WithParam("--url")
|
||||
}
|
||||
|
||||
source := driveExportInputSource{
|
||||
Type: spec.DocType,
|
||||
Token: spec.Token,
|
||||
Param: "--token",
|
||||
}
|
||||
|
||||
rawInput := spec.Token
|
||||
inputParam := "--token"
|
||||
if spec.URL != "" {
|
||||
rawInput = spec.URL
|
||||
inputParam = "--url"
|
||||
}
|
||||
|
||||
if ref, ok := common.ParseResourceURL(rawInput); ok {
|
||||
refType := normalizeDriveExportDocType(ref.Type)
|
||||
source = driveExportInputSource{
|
||||
Type: refType,
|
||||
Token: ref.Token,
|
||||
Param: inputParam,
|
||||
WasURL: true,
|
||||
}
|
||||
spec.Token = ref.Token
|
||||
if refType != "wiki" {
|
||||
if !isDriveExportDocType(refType) {
|
||||
return spec, source, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"%s URL type %q is not supported by drive +export; use a doc/docx/sheet/base/slides/wiki URL or token",
|
||||
inputParam,
|
||||
ref.Type,
|
||||
).WithParam(inputParam)
|
||||
}
|
||||
if spec.DocType == "wiki" {
|
||||
return spec, source, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--doc-type wiki conflicts with %s URL type %q",
|
||||
inputParam,
|
||||
refType,
|
||||
).
|
||||
WithParam("--doc-type").
|
||||
WithHint("remove --doc-type when passing --url; the CLI will infer %q from the URL", refType)
|
||||
}
|
||||
if spec.DocType != "" && spec.DocType != refType {
|
||||
return spec, source, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--doc-type %q conflicts with %s URL type %q",
|
||||
spec.DocType,
|
||||
inputParam,
|
||||
refType,
|
||||
).WithParam("--doc-type")
|
||||
}
|
||||
spec.DocType = refType
|
||||
} else if spec.DocType == "wiki" {
|
||||
spec.DocType = ""
|
||||
}
|
||||
return spec, source, nil
|
||||
}
|
||||
|
||||
if strings.Contains(rawInput, "://") {
|
||||
return spec, source, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported %s URL %q: use a recognized Lark document URL",
|
||||
inputParam,
|
||||
rawInput,
|
||||
).WithParam(inputParam)
|
||||
}
|
||||
if spec.URL != "" {
|
||||
return spec, source, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported --url %q: use a recognized Lark document URL",
|
||||
spec.URL,
|
||||
).WithParam("--url")
|
||||
}
|
||||
if spec.DocType == "" {
|
||||
return spec, source, errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc-type is required when --token is a bare token (allowed: %s)", driveExportInputDocTypeValues).
|
||||
WithParam("--doc-type").
|
||||
WithHint("if you have the original document link, prefer --url <document_url>; if this is a Wiki node token, use --doc-type wiki")
|
||||
}
|
||||
if spec.DocType == "wiki" {
|
||||
source.Type = "wiki"
|
||||
source.Token = spec.Token
|
||||
spec.DocType = ""
|
||||
}
|
||||
return spec, source, nil
|
||||
}
|
||||
|
||||
func normalizeDriveExportDocType(docType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(docType)) {
|
||||
case "base":
|
||||
return "bitable"
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(docType))
|
||||
}
|
||||
}
|
||||
|
||||
func isDriveExportDocType(docType string) bool {
|
||||
switch normalizeDriveExportDocType(docType) {
|
||||
case "doc", "docx", "sheet", "bitable", "slides":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func buildDriveExportTaskBody(spec driveExportSpec) map[string]interface{} {
|
||||
// createDriveExportTask starts the asynchronous export job and returns its
|
||||
// ticket for subsequent polling.
|
||||
func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec) (string, error) {
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
@@ -416,13 +193,8 @@ func buildDriveExportTaskBody(spec driveExportSpec) map[string]interface{} {
|
||||
if spec.OnlySchema {
|
||||
body["only_schema"] = true
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// createDriveExportTask starts the asynchronous export job and returns its
|
||||
// ticket for subsequent polling.
|
||||
func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec) (string, error) {
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, buildDriveExportTaskBody(spec))
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -434,99 +206,6 @@ func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec)
|
||||
return ticket, nil
|
||||
}
|
||||
|
||||
func resolveDriveExportWikiSource(ctx context.Context, runtime *common.RuntimeContext, spec driveExportSpec, wikiToken string) (driveExportSpec, driveExportWikiResolution, error) {
|
||||
wikiToken = strings.TrimSpace(wikiToken)
|
||||
if err := validate.ResourceName(wikiToken, "--token"); err != nil {
|
||||
return spec, driveExportWikiResolution{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node for export: %s\n", common.MaskToken(wikiToken))
|
||||
data, err := driveInspectCallWithRetry(ctx, func() (map[string]interface{}, error) {
|
||||
return runtime.CallAPITyped(
|
||||
"GET",
|
||||
"/open-apis/wiki/v2/spaces/get_node",
|
||||
map[string]interface{}{"token": wikiToken},
|
||||
nil,
|
||||
)
|
||||
})
|
||||
if err != nil {
|
||||
return spec, driveExportWikiResolution{}, err
|
||||
}
|
||||
|
||||
node := common.GetMap(data, "node")
|
||||
objType := normalizeDriveExportDocType(common.GetString(node, "obj_type"))
|
||||
objToken := common.GetString(node, "obj_token")
|
||||
if objType == "" || objToken == "" {
|
||||
return spec, driveExportWikiResolution{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data (obj_type=%q, obj_token=%q)", objType, objToken)
|
||||
}
|
||||
if !isDriveExportDocType(objType) {
|
||||
return spec, driveExportWikiResolution{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"wiki resolved to %q, but drive +export only supports doc, docx, sheet, bitable, and slides",
|
||||
objType,
|
||||
).WithParam("--token")
|
||||
}
|
||||
if spec.DocType != "" && spec.DocType != objType {
|
||||
return spec, driveExportWikiResolution{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"wiki resolved to %q, but --doc-type is %q; use --doc-type %s",
|
||||
objType,
|
||||
spec.DocType,
|
||||
objType,
|
||||
).WithParam("--doc-type")
|
||||
}
|
||||
|
||||
spec.Token = objToken
|
||||
spec.DocType = objType
|
||||
if err := validateDriveExportNormalizedSpec(spec); err != nil {
|
||||
return spec, driveExportWikiResolution{}, err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
|
||||
return spec, driveExportWikiResolution{
|
||||
Resolved: true,
|
||||
WikiToken: wikiToken,
|
||||
ObjToken: objToken,
|
||||
ObjType: objType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func createDriveExportTaskWithWikiFallback(ctx context.Context, runtime *common.RuntimeContext, spec driveExportSpec, source driveExportInputSource) (string, driveExportSpec, driveExportWikiResolution, error) {
|
||||
if source.Type == "wiki" {
|
||||
resolvedSpec, resolution, err := resolveDriveExportWikiSource(ctx, runtime, spec, source.Token)
|
||||
if err != nil {
|
||||
return "", spec, resolution, err
|
||||
}
|
||||
ticket, err := createDriveExportTask(runtime, resolvedSpec)
|
||||
return ticket, resolvedSpec, resolution, err
|
||||
}
|
||||
|
||||
ticket, err := createDriveExportTask(runtime, spec)
|
||||
if err == nil {
|
||||
return ticket, spec, driveExportWikiResolution{}, nil
|
||||
}
|
||||
if source.WasURL || !shouldRetryDriveExportAsWiki(err) {
|
||||
return "", spec, driveExportWikiResolution{}, err
|
||||
}
|
||||
|
||||
resolvedSpec, resolution, resolveErr := resolveDriveExportWikiSource(ctx, runtime, spec, spec.Token)
|
||||
if resolveErr != nil {
|
||||
return "", spec, driveExportWikiResolution{}, appendDriveExportRecoveryHint(
|
||||
err,
|
||||
fmt.Sprintf("export task rejected --token; attempted wiki node resolution also failed: %v", resolveErr),
|
||||
)
|
||||
}
|
||||
ticket, retryErr := createDriveExportTask(runtime, resolvedSpec)
|
||||
return ticket, resolvedSpec, resolution, retryErr
|
||||
}
|
||||
|
||||
func shouldRetryDriveExportAsWiki(err error) bool {
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem == nil {
|
||||
return false
|
||||
}
|
||||
return problem.Code == 1069914
|
||||
}
|
||||
|
||||
// getDriveExportStatus fetches the current backend state for a previously
|
||||
// created export task.
|
||||
func getDriveExportStatus(runtime *common.RuntimeContext, token, ticket string) (driveExportStatus, error) {
|
||||
|
||||
@@ -33,36 +33,10 @@ func TestValidateDriveExportSpec(t *testing.T) {
|
||||
name: "markdown docx ok",
|
||||
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "markdown"},
|
||||
},
|
||||
{
|
||||
name: "docx url infers doc type",
|
||||
spec: driveExportSpec{URL: "https://example.feishu.cn/docx/docxURL123", FileExtension: "pdf"},
|
||||
},
|
||||
{
|
||||
name: "wiki url can defer doc type until resolution",
|
||||
spec: driveExportSpec{URL: "https://example.feishu.cn/wiki/wikiURL123", FileExtension: "pdf"},
|
||||
},
|
||||
{
|
||||
name: "wiki url with doc-type wiki can defer doc type until resolution",
|
||||
spec: driveExportSpec{URL: "https://example.feishu.cn/wiki/wikiURL123", DocType: "wiki", FileExtension: "pdf"},
|
||||
},
|
||||
{
|
||||
name: "wiki token with doc-type wiki can defer doc type until resolution",
|
||||
spec: driveExportSpec{Token: "wiki123", DocType: "wiki", FileExtension: "pdf"},
|
||||
},
|
||||
{
|
||||
name: "bare token requires doc type",
|
||||
spec: driveExportSpec{Token: "docx123", FileExtension: "pdf"},
|
||||
wantErr: "--doc-type is required",
|
||||
},
|
||||
{
|
||||
name: "markdown non docx rejected",
|
||||
spec: driveExportSpec{Token: "doc123", DocType: "doc", FileExtension: "markdown"},
|
||||
wantErr: "cannot be exported as markdown",
|
||||
},
|
||||
{
|
||||
name: "docx csv rejected",
|
||||
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "csv"},
|
||||
wantErr: "cannot be exported as csv",
|
||||
wantErr: "only supports --doc-type docx",
|
||||
},
|
||||
{
|
||||
name: "csv without sub id rejected",
|
||||
@@ -98,17 +72,17 @@ func TestValidateDriveExportSpec(t *testing.T) {
|
||||
{
|
||||
name: "base non bitable rejected",
|
||||
spec: driveExportSpec{Token: "sheet123", DocType: "sheet", FileExtension: "base"},
|
||||
wantErr: "cannot be exported as base",
|
||||
wantErr: "only supports --doc-type bitable",
|
||||
},
|
||||
{
|
||||
name: "pptx non slides rejected",
|
||||
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "pptx"},
|
||||
wantErr: "cannot be exported as pptx",
|
||||
wantErr: "only supports --doc-type slides",
|
||||
},
|
||||
{
|
||||
name: "slides csv rejected",
|
||||
spec: driveExportSpec{Token: "slides123", DocType: "slides", FileExtension: "csv"},
|
||||
wantErr: "cannot be exported as csv",
|
||||
wantErr: "slides only supports",
|
||||
},
|
||||
{
|
||||
name: "unknown doc type rejected",
|
||||
@@ -139,29 +113,6 @@ func TestValidateDriveExportSpec(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDriveExportUnsupportedFormatHasHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateDriveExportSpec(driveExportSpec{
|
||||
Token: "docx123",
|
||||
DocType: "docx",
|
||||
FileExtension: "csv",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported format error, got nil")
|
||||
}
|
||||
var valErr *errs.ValidationError
|
||||
if !errors.As(err, &valErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if valErr.Param != "--file-extension" {
|
||||
t.Fatalf("param = %q, want --file-extension", valErr.Param)
|
||||
}
|
||||
if !strings.Contains(valErr.Hint, "docx, pdf, markdown") || !strings.Contains(valErr.Hint, "--url") {
|
||||
t.Fatalf("hint = %q, want allowed formats and URL retry guidance", valErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportMarkdownWritesFile(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
fetchStub := &httpmock.Stub{
|
||||
@@ -489,76 +440,6 @@ func TestDriveExportMarkdownRejectsMissingDocumentContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportURLInfersDocType(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_url"},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_url",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0,
|
||||
"file_token": "box_url",
|
||||
"file_name": "url-report",
|
||||
"file_extension": "pdf",
|
||||
"type": "docx",
|
||||
"file_size": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_url/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="url-report.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--url", "https://example.feishu.cn/docx/docxURL123",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var createBody map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
|
||||
t.Fatalf("unmarshal export_tasks body: %v", err)
|
||||
}
|
||||
if createBody["token"] != "docxURL123" {
|
||||
t.Fatalf("export_tasks body token = %v, want token from URL", createBody["token"])
|
||||
}
|
||||
if createBody["type"] != "docx" {
|
||||
t.Fatalf("export_tasks body type = %v, want inferred docx", createBody["type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -629,318 +510,6 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportWikiURLResolvesBeforeAsyncTask(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docxResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_wiki"},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_wiki",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0,
|
||||
"file_token": "box_wiki",
|
||||
"file_name": "wiki-report",
|
||||
"file_extension": "pdf",
|
||||
"type": "docx",
|
||||
"file_size": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_wiki/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="wiki-report.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--url", "https://example.feishu.cn/wiki/wikiNode123",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var createBody map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
|
||||
t.Fatalf("unmarshal export_tasks body: %v", err)
|
||||
}
|
||||
if createBody["token"] != "docxResolved" {
|
||||
t.Fatalf("export_tasks body token = %v, want resolved docx token", createBody["token"])
|
||||
}
|
||||
if createBody["type"] != "docx" {
|
||||
t.Fatalf("export_tasks body type = %v, want docx", createBody["type"])
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"wiki_token": "wikiNode123"`) {
|
||||
t.Fatalf("stdout missing wiki token context: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportBareWikiTypeResolvesBeforeAsyncTask(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docxResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_wiki_token"},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_wiki_token",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0,
|
||||
"file_token": "box_wiki_token",
|
||||
"file_name": "wiki-token-report",
|
||||
"file_extension": "pdf",
|
||||
"type": "docx",
|
||||
"file_size": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_wiki_token/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="wiki-token-report.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "wikiNodeBare",
|
||||
"--doc-type", "wiki",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var createBody map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
|
||||
t.Fatalf("unmarshal export_tasks body: %v", err)
|
||||
}
|
||||
if createBody["token"] != "docxResolved" {
|
||||
t.Fatalf("export_tasks body token = %v, want resolved docx token", createBody["token"])
|
||||
}
|
||||
if createBody["type"] != "docx" {
|
||||
t.Fatalf("export_tasks body type = %v, want resolved docx type", createBody["type"])
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"wiki_token": "wikiNodeBare"`) {
|
||||
t.Fatalf("stdout missing wiki token context: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportBareWikiTokenFallbackAfterFileTokenInvalid(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
firstCreate := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Status: 404,
|
||||
Body: map[string]interface{}{
|
||||
"code": 1069914,
|
||||
"msg": "file token invalid",
|
||||
"log_id": "20260708000000TEST",
|
||||
},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
return strings.Contains(string(body), `"token":"wikiNodeBare"`)
|
||||
},
|
||||
}
|
||||
reg.Register(firstCreate)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docxResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
retryCreate := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_retry"},
|
||||
},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
return strings.Contains(string(body), `"token":"docxResolved"`)
|
||||
},
|
||||
}
|
||||
reg.Register(retryCreate)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_retry",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0,
|
||||
"file_token": "box_retry",
|
||||
"file_name": "retry-report",
|
||||
"file_extension": "pdf",
|
||||
"type": "docx",
|
||||
"file_size": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_retry/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="retry-report.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "wikiNodeBare",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(firstCreate.CapturedBody) == 0 {
|
||||
t.Fatal("first export task request was not sent with the original token")
|
||||
}
|
||||
var retryBody map[string]interface{}
|
||||
if err := json.Unmarshal(retryCreate.CapturedBody, &retryBody); err != nil {
|
||||
t.Fatalf("unmarshal retry export_tasks body: %v", err)
|
||||
}
|
||||
if retryBody["token"] != "docxResolved" {
|
||||
t.Fatalf("retry export_tasks body token = %v, want resolved docx token", retryBody["token"])
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"wiki_token": "wikiNodeBare"`) {
|
||||
t.Fatalf("stdout missing wiki token context: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportWikiResolvedTypeMismatch(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "sheet",
|
||||
"obj_token": "shtResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "https://example.feishu.cn/wiki/wikiSheet123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected type mismatch error, got nil")
|
||||
}
|
||||
var valErr *errs.ValidationError
|
||||
if !errors.As(err, &valErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(valErr.Message, `wiki resolved to "sheet"`) {
|
||||
t.Fatalf("error message = %q, want resolved type", valErr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveExportEmptyOutputDirDownloadsToCwd guards the export refactor: an
|
||||
// explicit empty --output-dir must still download to the current directory
|
||||
// (normalized to "."), not trigger the export-only no-download path that the
|
||||
|
||||
@@ -184,6 +184,7 @@ var DrivePull = common.Shortcut{
|
||||
|
||||
var downloaded, skipped, failed, deletedLocal int
|
||||
downloadFailed := 0
|
||||
aborted := false
|
||||
items := make([]drivePullItem, 0)
|
||||
|
||||
// Deterministic iteration order for output stability.
|
||||
@@ -194,7 +195,7 @@ var DrivePull = common.Shortcut{
|
||||
sort.Strings(downloadablePaths)
|
||||
|
||||
for _, rel := range downloadablePaths {
|
||||
if drivePullHasTerminalFailure(items) {
|
||||
if aborted {
|
||||
break
|
||||
}
|
||||
targetFile := remoteFiles[rel]
|
||||
@@ -232,6 +233,7 @@ var DrivePull = common.Shortcut{
|
||||
failed++
|
||||
downloadFailed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +pull after terminal %s failure: %v\n", item.Phase, err)
|
||||
break
|
||||
}
|
||||
@@ -298,7 +300,7 @@ var DrivePull = common.Shortcut{
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"deleted_local": deletedLocal,
|
||||
"aborted": drivePullHasTerminalFailure(items),
|
||||
"aborted": aborted,
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
@@ -347,15 +349,6 @@ func drivePullFailedItem(relPath, fileToken, sourceID, action, phase string, err
|
||||
return item, decision.Terminal
|
||||
}
|
||||
|
||||
func drivePullHasTerminalFailure(items []drivePullItem) bool {
|
||||
for _, item := range items {
|
||||
if driveTerminalBatchErrorClass(item.ErrorClass) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// drivePullDownload streams one Drive file into the local mirror target and
|
||||
// then best-effort aligns the local mtime to Drive's modified_time.
|
||||
func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target, remoteModifiedTime string) error {
|
||||
|
||||
@@ -35,6 +35,7 @@ type drivePushItem struct {
|
||||
Version string `json:"version,omitempty"`
|
||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
ErrorClass string `json:"error_class,omitempty"`
|
||||
Code int `json:"code,omitempty"`
|
||||
@@ -48,6 +49,7 @@ type driveBatchFailureDecision struct {
|
||||
Subtype string
|
||||
Retryable bool
|
||||
Terminal bool
|
||||
Hint string
|
||||
}
|
||||
|
||||
// DrivePush is a one-way, file-level mirror from a local directory onto a
|
||||
@@ -240,6 +242,7 @@ var DrivePush = common.Shortcut{
|
||||
// locally and now on Drive too), which is the worst-of-both-worlds
|
||||
// outcome the review flagged.
|
||||
uploadFailed := false
|
||||
aborted := false
|
||||
|
||||
// folderCache holds rel_path → folder_token. Seeded from the remote
|
||||
// listing (so we don't recreate folders that already exist) and
|
||||
@@ -266,6 +269,7 @@ var DrivePush = common.Shortcut{
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||
break
|
||||
}
|
||||
@@ -284,7 +288,7 @@ var DrivePush = common.Shortcut{
|
||||
|
||||
for _, rel := range localPaths {
|
||||
localFile := localFiles[rel]
|
||||
if uploadFailed && drivePushHasTerminalFailure(items) {
|
||||
if uploadFailed && aborted {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -301,6 +305,7 @@ var DrivePush = common.Shortcut{
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, parentErr)
|
||||
break
|
||||
}
|
||||
@@ -332,6 +337,7 @@ var DrivePush = common.Shortcut{
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
|
||||
break
|
||||
}
|
||||
@@ -350,6 +356,7 @@ var DrivePush = common.Shortcut{
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||
break
|
||||
}
|
||||
@@ -362,6 +369,7 @@ var DrivePush = common.Shortcut{
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
|
||||
break
|
||||
}
|
||||
@@ -407,10 +415,15 @@ var DrivePush = common.Shortcut{
|
||||
continue
|
||||
}
|
||||
if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil {
|
||||
if drivePushIsAlreadyDeleted(err) {
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "already_deleted"})
|
||||
continue
|
||||
}
|
||||
item, terminal := drivePushFailedItem(rel, entry.FileToken, "delete_failed", "delete", 0, err)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, err)
|
||||
abortDelete = true
|
||||
break
|
||||
@@ -429,7 +442,7 @@ var DrivePush = common.Shortcut{
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"deleted_remote": deletedRemote,
|
||||
"aborted": drivePushHasTerminalFailure(items),
|
||||
"aborted": aborted,
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
@@ -567,6 +580,7 @@ func drivePushFailedItem(relPath, fileToken, action, phase string, sizeBytes int
|
||||
Action: action,
|
||||
SizeBytes: sizeBytes,
|
||||
Error: err.Error(),
|
||||
Hint: decision.Hint,
|
||||
Phase: phase,
|
||||
ErrorClass: decision.Class,
|
||||
Code: decision.Code,
|
||||
@@ -613,6 +627,10 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
|
||||
decision.Class = "file_size_limit"
|
||||
case problem.Code == 1062009:
|
||||
decision.Class = "upload_size_mismatch"
|
||||
case problem.Code == 1061044:
|
||||
decision.Class = "parent_node_missing"
|
||||
decision.Terminal = true
|
||||
decision.Hint = "The destination parent folder no longer exists or is not visible. Verify --folder-token, folder permissions, and whether a parent directory was deleted during push before retrying."
|
||||
case problem.Subtype == errs.SubtypeNotFound || problem.Code == 1061007:
|
||||
decision.Class = "remote_not_found"
|
||||
case problem.Subtype == errs.SubtypeServerError || problem.Code == 1061001 || problem.Code == 2200:
|
||||
@@ -626,22 +644,9 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
|
||||
return decision
|
||||
}
|
||||
|
||||
func drivePushHasTerminalFailure(items []drivePushItem) bool {
|
||||
for _, item := range items {
|
||||
if driveTerminalBatchErrorClass(item.ErrorClass) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func driveTerminalBatchErrorClass(errorClass string) bool {
|
||||
switch errorClass {
|
||||
case "app_scope_missing", "user_scope_missing", "permission_denied", "invalid_api_parameters", "rate_limited", "server_error":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
func drivePushIsAlreadyDeleted(err error) bool {
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
return ok && problem.Code == 1061007
|
||||
}
|
||||
|
||||
func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]driveRemoteEntry, map[string]driveRemoteEntry, map[string][]driveRemoteEntry, error) {
|
||||
|
||||
@@ -732,6 +732,65 @@ func TestDrivePushDeleteRemoteAbortsAfterTerminalFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushDeleteRemoteTreatsAlreadyDeletedAsNoop(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_orphan", "name": "orphan.txt", "type": "file"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/tok_orphan",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1061007,
|
||||
"msg": "file has been delete.",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--delete-remote",
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("already-deleted remote should be an idempotent success, got: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
||||
if got := summary["failed"]; got != float64(0) {
|
||||
t.Fatalf("summary.failed = %v, want 0", got)
|
||||
}
|
||||
if got := summary["deleted_remote"]; got != float64(0) {
|
||||
t.Fatalf("summary.deleted_remote = %v, want 0 because CLI did not delete it in this run", got)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||
}
|
||||
item := items[0]
|
||||
if item["action"] != "already_deleted" || item["file_token"] != "tok_orphan" {
|
||||
t.Fatalf("unexpected already-deleted item: %#v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushNewestOverwritesChosenDuplicateAndDeletesSibling(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
@@ -1137,6 +1196,78 @@ func TestDrivePushAbortsAfterUploadParamsError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushAbortsAfterUploadParentNodeMissing(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "a.txt"), []byte("A"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile a: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "b.txt"), []byte("B"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile b: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1061044,
|
||||
"msg": "parent node not exist.",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Fatalf("summary.failed = %v, want 1", got)
|
||||
}
|
||||
if got := summary["aborted"]; got != true {
|
||||
t.Fatalf("summary.aborted = %v, want true", got)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||
}
|
||||
item := items[0]
|
||||
if item["rel_path"] != "a.txt" || item["phase"] != "upload" || item["error_class"] != "parent_node_missing" {
|
||||
t.Fatalf("unexpected failed item: %#v", item)
|
||||
}
|
||||
if item["code"] != float64(1061044) || item["subtype"] != "not_found" || item["retryable"] != false {
|
||||
t.Fatalf("unexpected failure metadata: %#v", item)
|
||||
}
|
||||
if got, _ := item["hint"].(string); !strings.Contains(got, "--folder-token") || !strings.Contains(got, "parent") {
|
||||
t.Fatalf("hint should point at the destination parent folder, got item=%#v", item)
|
||||
}
|
||||
for _, item := range items {
|
||||
if item["rel_path"] == "b.txt" {
|
||||
t.Fatalf("parent-node missing must abort before b.txt, got items=%#v", items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushAbortsAfterCreateFolderMissingScope(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
|
||||
@@ -268,6 +268,7 @@ var DriveSync = common.Shortcut{
|
||||
|
||||
// --- Phase 2: Execute sync operations ---
|
||||
var pulled, pushed, skipped, failed int
|
||||
aborted := false
|
||||
items := make([]driveSyncItem, 0)
|
||||
|
||||
// Build push infrastructure: local walk for push + remote views + folder cache.
|
||||
@@ -286,16 +287,21 @@ var DriveSync = common.Shortcut{
|
||||
// Mirror local directory structure first (same as +push), so
|
||||
// empty local directories are not silently dropped.
|
||||
for _, relDir := range localDirs {
|
||||
if driveSyncHasTerminalFailure(items) {
|
||||
if aborted {
|
||||
break
|
||||
}
|
||||
if _, alreadyRemote := folderCache[relDir]; alreadyRemote {
|
||||
continue
|
||||
}
|
||||
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
|
||||
item, _ := driveSyncFailedItem(relDir, "", "failed", "push", "create_folder", ensureErr)
|
||||
item, terminal := driveSyncFailedItem(relDir, "", "failed", "push", "create_folder", ensureErr)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
items = append(items, driveSyncItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created", Direction: "push"})
|
||||
@@ -304,7 +310,7 @@ var DriveSync = common.Shortcut{
|
||||
|
||||
// 2a. Pull new_remote files.
|
||||
for _, entry := range newRemote {
|
||||
if driveSyncHasTerminalFailure(items) {
|
||||
if aborted {
|
||||
break
|
||||
}
|
||||
targetFile, ok := pullRemoteFiles[entry.RelPath]
|
||||
@@ -318,6 +324,7 @@ var DriveSync = common.Shortcut{
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
|
||||
break
|
||||
}
|
||||
@@ -329,7 +336,7 @@ var DriveSync = common.Shortcut{
|
||||
|
||||
// 2b. Push new_local files.
|
||||
for _, entry := range newLocal {
|
||||
if driveSyncHasTerminalFailure(items) {
|
||||
if aborted {
|
||||
break
|
||||
}
|
||||
localFile, ok := pushLocalFiles[entry.RelPath]
|
||||
@@ -341,9 +348,14 @@ var DriveSync = common.Shortcut{
|
||||
parentRel := drivePushParentRel(entry.RelPath)
|
||||
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
|
||||
if ensureErr != nil {
|
||||
item, _ := driveSyncFailedItem(entry.RelPath, "", "failed", "push", "create_folder", ensureErr)
|
||||
item, terminal := driveSyncFailedItem(entry.RelPath, "", "failed", "push", "create_folder", ensureErr)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
|
||||
@@ -352,6 +364,7 @@ var DriveSync = common.Shortcut{
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
|
||||
break
|
||||
}
|
||||
@@ -363,7 +376,7 @@ var DriveSync = common.Shortcut{
|
||||
|
||||
// 2c. Resolve modified files by --on-conflict strategy.
|
||||
for _, entry := range modified {
|
||||
if driveSyncHasTerminalFailure(items) {
|
||||
if aborted {
|
||||
break
|
||||
}
|
||||
remoteFile := remoteFiles[entry.RelPath]
|
||||
@@ -397,6 +410,7 @@ var DriveSync = common.Shortcut{
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
|
||||
break
|
||||
}
|
||||
@@ -415,9 +429,14 @@ var DriveSync = common.Shortcut{
|
||||
}
|
||||
parentToken, parentErr := drivePushEnsureFolder(ctx, runtime, folderToken, drivePushParentRel(entry.RelPath), folderCache)
|
||||
if parentErr != nil {
|
||||
item, _ := driveSyncFailedItem(entry.RelPath, existingToken, "failed", "push", "create_folder", parentErr)
|
||||
item, terminal := driveSyncFailedItem(entry.RelPath, existingToken, "failed", "push", "create_folder", parentErr)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, parentErr)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, existingToken, parentToken)
|
||||
@@ -435,6 +454,7 @@ var DriveSync = common.Shortcut{
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
|
||||
break
|
||||
}
|
||||
@@ -503,6 +523,7 @@ var DriveSync = common.Shortcut{
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, downloadErr)
|
||||
break
|
||||
}
|
||||
@@ -531,7 +552,7 @@ var DriveSync = common.Shortcut{
|
||||
"pushed": pushed,
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"aborted": driveSyncHasTerminalFailure(items),
|
||||
"aborted": aborted,
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
@@ -577,15 +598,6 @@ func driveSyncFailedItem(relPath, fileToken, action, direction, phase string, er
|
||||
return item, decision.Terminal
|
||||
}
|
||||
|
||||
func driveSyncHasTerminalFailure(items []driveSyncItem) bool {
|
||||
for _, item := range items {
|
||||
if driveTerminalBatchErrorClass(item.ErrorClass) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// driveSyncAskConflict prompts the user for a conflict resolution strategy
|
||||
// for a single file. Returns the strategy string, or empty string if the
|
||||
// user chose to skip.
|
||||
|
||||
@@ -715,9 +715,15 @@ func markdownUploadProblem(err error, action string) error {
|
||||
case 90003087:
|
||||
appendMarkdownProblemHint(err, "The current tenant or user may not have document capabilities enabled. Ask an administrator to verify document-module access.")
|
||||
case 1061003, 1061044:
|
||||
appendMarkdownProblemHint(err, "Check whether the target folder or wiki node still exists, and verify the token you passed to the command.")
|
||||
appendMarkdownProblemHint(err, "Check whether the target folder or wiki node still exists, and verify the parent token type. For Drive folders, pass --folder-token with a Drive folder token/URL; for wiki nodes, pass --wiki-token with a wiki node token/URL.")
|
||||
case 1061004, 1062501:
|
||||
appendMarkdownProblemHint(err, "Check whether the current identity has write access to the target folder or wiki node.")
|
||||
case 1061101:
|
||||
appendMarkdownProblemHint(err, "The target Drive/wiki storage quota is exhausted. Free space, choose another parent folder/wiki node, or ask an administrator to raise quota before retrying.")
|
||||
case 233523001:
|
||||
appendMarkdownProblemHint(err, "The upstream document service returned a transient server error. Retry later; if it repeats, keep the log_id/request_id for service-side investigation.")
|
||||
case 99991400:
|
||||
appendMarkdownProblemHint(err, "The upload API is rate limited. Stop immediate retries and retry later with exponential backoff.")
|
||||
}
|
||||
}
|
||||
return err
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -30,27 +31,19 @@ var MarkdownCreate = common.Shortcut{
|
||||
Tips: []string{
|
||||
"Omit both --folder-token and --wiki-token to create the Markdown file in the caller's Drive root folder.",
|
||||
"Use --wiki-token <wiki_node_token> to create the Markdown file under a wiki node; the shortcut maps this to parent_type=wiki automatically.",
|
||||
"--folder-token and --wiki-token also accept full Lark URLs and normalize them to the required token.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateMarkdownSpec(runtime, markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
}, true)
|
||||
spec, err := readMarkdownCreateSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validateMarkdownSpec(runtime, spec, true)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
spec, err := readMarkdownCreateSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
fileSize, err := markdownSourceSize(runtime, spec)
|
||||
if err != nil {
|
||||
@@ -71,14 +64,9 @@ var MarkdownCreate = common.Shortcut{
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
spec, err := readMarkdownCreateSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileSize, err := markdownSourceSize(runtime, spec)
|
||||
if err != nil {
|
||||
@@ -115,3 +103,139 @@ var MarkdownCreate = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func readMarkdownCreateSpec(runtime *common.RuntimeContext) (markdownUploadSpec, error) {
|
||||
spec := markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
}
|
||||
return normalizeMarkdownCreateTargetSpec(spec)
|
||||
}
|
||||
|
||||
func normalizeMarkdownCreateTargetSpec(spec markdownUploadSpec) (markdownUploadSpec, error) {
|
||||
if spec.FolderToken != "" {
|
||||
token, err := normalizeMarkdownFolderToken(spec.FolderToken)
|
||||
if err != nil {
|
||||
return markdownUploadSpec{}, err
|
||||
}
|
||||
spec.FolderToken = token
|
||||
}
|
||||
if spec.WikiToken != "" {
|
||||
token, err := normalizeMarkdownWikiToken(spec.WikiToken)
|
||||
if err != nil {
|
||||
return markdownUploadSpec{}, err
|
||||
}
|
||||
spec.WikiToken = token
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func normalizeMarkdownFolderToken(token string) (string, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if strings.Contains(token, "://") {
|
||||
ref, ok := common.ParseResourceURL(token)
|
||||
if !ok {
|
||||
return "", markdownValidationParamError("--folder-token", "--folder-token URL is unsupported").
|
||||
WithHint("Pass a Drive folder URL or raw folder token.")
|
||||
}
|
||||
if ref.Type != "folder" {
|
||||
return "", markdownValidationParamError("--folder-token",
|
||||
"--folder-token must identify a Drive folder; got a %s URL",
|
||||
ref.Type,
|
||||
).WithHint("Use --wiki-token for wiki nodes or pass a Drive folder URL/token.")
|
||||
}
|
||||
if err := validateMarkdownTargetTokenName(ref.Token, "--folder-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ref.Token, nil
|
||||
}
|
||||
if err := rejectMarkdownPartialToken(token, "--folder-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch markdownKnownResourceTokenKind(token) {
|
||||
case "wiki":
|
||||
return "", markdownValidationParamError("--folder-token", "--folder-token looks like a wiki node token").
|
||||
WithHint("Pass it with --wiki-token instead.")
|
||||
case "doc", "docx", "sheet", "bitable", "mindnote", "slides", "file":
|
||||
return "", markdownValidationParamError("--folder-token", "--folder-token must be a Drive folder token, not a %s token", markdownKnownResourceTokenKind(token))
|
||||
}
|
||||
if err := validateMarkdownTargetTokenName(token, "--folder-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func normalizeMarkdownWikiToken(token string) (string, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if strings.Contains(token, "://") {
|
||||
ref, ok := common.ParseResourceURL(token)
|
||||
if !ok {
|
||||
return "", markdownValidationParamError("--wiki-token", "--wiki-token URL is unsupported").
|
||||
WithHint("Pass a wiki node URL or raw wiki node token.")
|
||||
}
|
||||
if ref.Type != "wiki" {
|
||||
return "", markdownValidationParamError("--wiki-token",
|
||||
"--wiki-token must identify a wiki node; got a %s URL",
|
||||
ref.Type,
|
||||
).WithHint("Resolve document URLs with `lark-cli wiki +node-get --node-token <url>` and use the returned node_token.")
|
||||
}
|
||||
if err := validateMarkdownTargetTokenName(ref.Token, "--wiki-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ref.Token, nil
|
||||
}
|
||||
if err := rejectMarkdownPartialToken(token, "--wiki-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if kind := markdownKnownResourceTokenKind(token); kind != "" && kind != "wiki" {
|
||||
return "", markdownValidationParamError("--wiki-token", "--wiki-token must be a wiki node token, not a %s token", kind)
|
||||
}
|
||||
if err := validateMarkdownTargetTokenName(token, "--wiki-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func rejectMarkdownPartialToken(token, flagName string) error {
|
||||
if strings.ContainsAny(token, "/?#") {
|
||||
return markdownValidationParamError(flagName, "%s must be a raw token, not a path, query, or fragment", flagName).
|
||||
WithHint("Pass a full Lark URL, or copy only the token value without path/query/fragment characters.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMarkdownTargetTokenName(token, flagName string) error {
|
||||
if err := validate.ResourceName(token, flagName); err != nil {
|
||||
return markdownValidationParamError(flagName, "%s", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func markdownKnownResourceTokenKind(token string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(token))
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "wik"):
|
||||
return "wiki"
|
||||
case strings.HasPrefix(lower, "docx"):
|
||||
return "docx"
|
||||
case strings.HasPrefix(lower, "doc"):
|
||||
return "doc"
|
||||
case strings.HasPrefix(lower, "sht"):
|
||||
return "sheet"
|
||||
case strings.HasPrefix(lower, "bas"):
|
||||
return "bitable"
|
||||
case strings.HasPrefix(lower, "mn"):
|
||||
return "mindnote"
|
||||
case strings.HasPrefix(lower, "sld"):
|
||||
return "slides"
|
||||
case strings.HasPrefix(lower, "box"), strings.HasPrefix(lower, "file"):
|
||||
return "file"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,6 +446,173 @@ func TestMarkdownCreateDryRunWithWikiToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateDryRunNormalizesFolderURL(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello",
|
||||
"--folder-token", "https://feishu.cn/drive/folder/fldcnMarkdownTarget",
|
||||
"--dry-run",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"parent_type": "explorer"`) {
|
||||
t.Fatalf("dry-run missing explorer parent_type: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"parent_node": "fldcnMarkdownTarget"`) {
|
||||
t.Fatalf("dry-run did not normalize folder URL to token: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "https://feishu.cn/drive/folder/") {
|
||||
t.Fatalf("dry-run leaked raw folder URL instead of token: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateRejectsWikiURLInFolderToken(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello",
|
||||
"--folder-token", "https://feishu.cn/wiki/wikcnWrongFlag",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected folder-token URL type error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, "must identify a Drive folder") || !strings.Contains(p.Hint, "Use --wiki-token") {
|
||||
t.Fatalf("expected folder-token URL type error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateRejectsDocURLInWikiToken(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello",
|
||||
"--wiki-token", "https://feishu.cn/docx/docxWrongFlag",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected wiki-token URL type error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, "must identify a wiki node") || !strings.Contains(p.Hint, "+node-get") {
|
||||
t.Fatalf("expected wiki-token URL type error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMarkdownTargetTokensRejectAmbiguousInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
run func() (string, error)
|
||||
wantMsg string
|
||||
wantHint string
|
||||
}{
|
||||
{
|
||||
name: "wiki token passed as folder token",
|
||||
run: func() (string, error) { return normalizeMarkdownFolderToken("wik_placeholder_wrong") },
|
||||
wantMsg: "--folder-token looks like a wiki node token",
|
||||
wantHint: "--wiki-token",
|
||||
},
|
||||
{
|
||||
name: "folder token path fragment",
|
||||
run: func() (string, error) { return normalizeMarkdownFolderToken("folder_token/child") },
|
||||
wantMsg: "--folder-token must be a raw token",
|
||||
wantHint: "full Lark URL",
|
||||
},
|
||||
{
|
||||
name: "doc token passed as wiki token",
|
||||
run: func() (string, error) { return normalizeMarkdownWikiToken("docx_placeholder_wrong") },
|
||||
wantMsg: "--wiki-token must be a wiki node token",
|
||||
wantHint: "",
|
||||
},
|
||||
{
|
||||
name: "wiki token query fragment",
|
||||
run: func() (string, error) { return normalizeMarkdownWikiToken("wik_placeholder?from=copy") },
|
||||
wantMsg: "--wiki-token must be a raw token",
|
||||
wantHint: "path/query/fragment",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := tt.run()
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, tt.wantMsg) {
|
||||
t.Fatalf("message = %q, want substring %q", p.Message, tt.wantMsg)
|
||||
}
|
||||
if tt.wantHint != "" && !strings.Contains(p.Hint, tt.wantHint) {
|
||||
t.Fatalf("hint = %q, want substring %q", p.Hint, tt.wantHint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMarkdownTargetTokensAcceptRawTokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
folderToken, err := normalizeMarkdownFolderToken("folder_token_raw")
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeMarkdownFolderToken() error = %v", err)
|
||||
}
|
||||
if folderToken != "folder_token_raw" {
|
||||
t.Fatalf("folder token = %q", folderToken)
|
||||
}
|
||||
|
||||
wikiToken, err := normalizeMarkdownWikiToken("wik_placeholder_raw")
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeMarkdownWikiToken() error = %v", err)
|
||||
}
|
||||
if wikiToken != "wik_placeholder_raw" {
|
||||
t.Fatalf("wiki token = %q", wikiToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownUploadProblemAddsQuotaAndServerHints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
quotaErr := errs.NewAPIError(errs.SubtypeQuotaExceeded, "file quota exceeded").WithCode(1061101)
|
||||
got := markdownUploadProblem(quotaErr, markdownUploadAllAction)
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf(quotaErr) ok=false")
|
||||
}
|
||||
if !strings.Contains(p.Hint, "storage quota is exhausted") {
|
||||
t.Fatalf("quota hint = %q", p.Hint)
|
||||
}
|
||||
|
||||
serverErr := errs.NewAPIError(errs.SubtypeServerError, "NA").WithCode(233523001).WithRetryable()
|
||||
got = markdownUploadProblem(serverErr, markdownUploadAllAction)
|
||||
p, ok = errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf(serverErr) ok=false")
|
||||
}
|
||||
if !p.Retryable || !strings.Contains(p.Hint, "transient server error") {
|
||||
t.Fatalf("server retryable=%v hint=%q", p.Retryable, p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateDryRunReportsSourceFileError(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
|
||||
@@ -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()...)
|
||||
|
||||
@@ -6,6 +6,7 @@ package wiki
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -26,3 +27,17 @@ func wikiNodeURL(brand core.LarkBrand, node *wikiNodeRecord) string {
|
||||
}
|
||||
return common.BuildResourceURL(brand, "wiki", node.NodeToken)
|
||||
}
|
||||
|
||||
func appendWikiProblemHint(err error, hint string) error {
|
||||
if strings.TrimSpace(hint) == "" {
|
||||
return err
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if strings.TrimSpace(p.Hint) != "" {
|
||||
p.Hint = p.Hint + "\n" + hint
|
||||
} else {
|
||||
p.Hint = hint
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ package wiki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -130,6 +132,147 @@ func TestWikiNodeListRequiresSpaceID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListRejectsNonNumericSpaceID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||
"+node-list", "--space-id", "wikcnABC", "--as", "user",
|
||||
}, factory, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected numeric space_id validation error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--space-id" {
|
||||
t.Fatalf("problem = %#v param=%q, want validation/invalid_argument/--space-id", p, validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(p.Message, "--space-id must be a numeric wiki space_id") || !strings.Contains(p.Hint, "+space-list") {
|
||||
t.Fatalf("expected numeric space_id validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListRejectsDocumentURLAsParentNodeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||
"+node-list",
|
||||
"--space-id", "7211568716812369922",
|
||||
"--parent-node-token", "https://feishu.cn/docx/docxABC",
|
||||
"--as", "user",
|
||||
}, factory, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected parent-node-token URL type validation error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--parent-node-token" {
|
||||
t.Fatalf("problem = %#v param=%q, want validation/invalid_argument/--parent-node-token", p, validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(p.Message, "must identify a wiki node") || !strings.Contains(p.Hint, "+node-get") {
|
||||
t.Fatalf("expected parent-node-token URL type validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListNormalizesWikiURLParentNodeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
token, err := normalizeWikiNodeListParentToken("https://feishu.cn/wiki/wikcnPARENT?from=copy")
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeWikiNodeListParentToken() error = %v", err)
|
||||
}
|
||||
if token != "wikcnPARENT" {
|
||||
t.Fatalf("token = %q, want wikcnPARENT", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListRejectsAmbiguousSpaceAndParentTokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if err := validateWikiNodeListSpaceID("https://example.invalid/wiki/space"); err == nil {
|
||||
t.Fatalf("expected URL space-id validation error")
|
||||
} else {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, "not a URL or path") || !strings.Contains(p.Hint, "+space-list") {
|
||||
t.Fatalf("problem = %#v, want URL/path message and +space-list hint", p)
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantMsg string
|
||||
}{
|
||||
{
|
||||
name: "partial wiki path",
|
||||
input: "wik_placeholder/child",
|
||||
wantMsg: "raw wiki node token",
|
||||
},
|
||||
{
|
||||
name: "document token",
|
||||
input: "docx_placeholder_parent",
|
||||
wantMsg: "must be a wiki node token",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := normalizeWikiNodeListParentToken(tt.input)
|
||||
if err == nil {
|
||||
t.Fatalf("expected parent token validation error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, tt.wantMsg) {
|
||||
t.Fatalf("message = %q, want substring %q", p.Message, tt.wantMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListAcceptsEmptyParentToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
token, err := normalizeWikiNodeListParentToken("")
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeWikiNodeListParentToken(empty) error = %v", err)
|
||||
}
|
||||
if token != "" {
|
||||
t.Fatalf("token = %q, want empty", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListProblemAddsActionableHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := errs.NewAPIError(errs.SubtypeInvalidParameters, "param err: invalid page_token").WithCode(131002)
|
||||
got := wikiNodeListProblem(err, nil)
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok=false")
|
||||
}
|
||||
if !strings.Contains(p.Hint, "page token is invalid or stale") {
|
||||
t.Fatalf("hint = %q, want invalid page token guidance", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
@@ -137,14 +280,14 @@ func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
|
||||
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"space_id": "space_123",
|
||||
"space_id": "7211568716812369922",
|
||||
"node_token": "wik_node_1",
|
||||
"obj_token": "docx_1",
|
||||
"obj_type": "docx",
|
||||
@@ -154,7 +297,7 @@ func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
|
||||
"has_child": true,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"space_id": "space_123",
|
||||
"space_id": "7211568716812369922",
|
||||
"node_token": "wik_node_2",
|
||||
"obj_token": "docx_2",
|
||||
"obj_type": "docx",
|
||||
@@ -170,7 +313,7 @@ func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||
"+node-list", "--space-id", "space_123", "--as", "bot",
|
||||
"+node-list", "--space-id", "7211568716812369922", "--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
@@ -211,14 +354,14 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes?page_size=50&parent_node_token=wik_parent",
|
||||
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes?page_size=50&parent_node_token=wik_parent",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"space_id": "space_123",
|
||||
"space_id": "7211568716812369922",
|
||||
"node_token": "wik_child",
|
||||
"obj_token": "docx_child",
|
||||
"obj_type": "docx",
|
||||
@@ -235,7 +378,7 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||
"+node-list", "--space-id", "space_123", "--parent-node-token", "wik_parent", "--as", "bot",
|
||||
"+node-list", "--space-id", "7211568716812369922", "--parent-node-token", "wik_parent", "--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
@@ -286,7 +429,7 @@ func TestWikiNodeListResolvesMyLibraryForUser(t *testing.T) {
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"space": map[string]interface{}{
|
||||
"space_id": "space_personal_42",
|
||||
"space_id": "7211568716812369923",
|
||||
"name": "My Library",
|
||||
"space_type": "my_library",
|
||||
},
|
||||
@@ -296,14 +439,14 @@ func TestWikiNodeListResolvesMyLibraryForUser(t *testing.T) {
|
||||
// Step 2: list nodes in the resolved space.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_personal_42/nodes",
|
||||
URL: "/open-apis/wiki/v2/spaces/7211568716812369923/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"space_id": "space_personal_42",
|
||||
"space_id": "7211568716812369923",
|
||||
"node_token": "wik_personal_1",
|
||||
"title": "Personal Note",
|
||||
},
|
||||
@@ -334,8 +477,8 @@ func TestWikiNodeListResolvesMyLibraryForUser(t *testing.T) {
|
||||
if envelope.Meta.Count != 1 {
|
||||
t.Fatalf("meta.count = %v, want 1", envelope.Meta.Count)
|
||||
}
|
||||
if envelope.Data.Nodes[0]["space_id"] != "space_personal_42" {
|
||||
t.Fatalf("nodes[0].space_id = %v, want space_personal_42", envelope.Data.Nodes[0]["space_id"])
|
||||
if envelope.Data.Nodes[0]["space_id"] != "7211568716812369923" {
|
||||
t.Fatalf("nodes[0].space_id = %v, want 7211568716812369923", envelope.Data.Nodes[0]["space_id"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -758,21 +901,21 @@ func TestWikiNodeListDefaultIsSinglePage(t *testing.T) {
|
||||
// test pins down the "default = single page" contract.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
|
||||
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": true,
|
||||
"page_token": "tok_next",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"space_id": "space_123", "node_token": "wik_1", "title": "First"},
|
||||
map[string]interface{}{"space_id": "7211568716812369922", "node_token": "wik_1", "title": "First"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||
"+node-list", "--space-id", "space_123", "--as", "bot",
|
||||
"+node-list", "--space-id", "7211568716812369922", "--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
@@ -802,14 +945,14 @@ func TestWikiNodeListPrettyFormatRendersFields(t *testing.T) {
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
|
||||
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"space_id": "space_123",
|
||||
"space_id": "7211568716812369922",
|
||||
"node_token": "wik_1",
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docx_1",
|
||||
@@ -822,7 +965,7 @@ func TestWikiNodeListPrettyFormatRendersFields(t *testing.T) {
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||
"+node-list", "--space-id", "space_123", "--format", "pretty", "--as", "bot",
|
||||
"+node-list", "--space-id", "7211568716812369922", "--format", "pretty", "--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
|
||||
@@ -48,27 +48,19 @@ var WikiNodeList = common.Shortcut{
|
||||
"--space-id my_library is a per-user alias and is only valid with --as user.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spaceID := strings.TrimSpace(runtime.Str("space-id"))
|
||||
// my_library is a per-user personal-library alias; it has no meaning
|
||||
// for a tenant_access_token (--as bot), so reject early with a clear
|
||||
// hint instead of deferring to API-time errors. Matches the contract
|
||||
// used by +node-create and +move.
|
||||
if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity does not support --space-id my_library; use an explicit --space-id").WithParam("--space-id")
|
||||
}
|
||||
if err := validateOptionalResourceName(spaceID, "--space-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOptionalResourceName(strings.TrimSpace(runtime.Str("parent-node-token")), "--parent-node-token"); err != nil {
|
||||
if _, err := readWikiNodeListSpec(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateWikiListPagination(runtime, wikiNodeListMaxPageSize)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spaceID := strings.TrimSpace(runtime.Str("space-id"))
|
||||
spec, err := readWikiNodeListSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
params := map[string]interface{}{"page_size": runtime.Int("page-size")}
|
||||
if pt := strings.TrimSpace(runtime.Str("parent-node-token")); pt != "" {
|
||||
params["parent_node_token"] = pt
|
||||
if spec.ParentNodeToken != "" {
|
||||
params["parent_node_token"] = spec.ParentNodeToken
|
||||
}
|
||||
if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" {
|
||||
params["page_token"] = pt
|
||||
@@ -80,7 +72,7 @@ var WikiNodeList = common.Shortcut{
|
||||
// When the caller passes my_library, +node-list must first resolve it
|
||||
// to the real per-user space_id before listing nodes, mirroring the
|
||||
// two-step orchestration used by +node-create.
|
||||
if spaceID == wikiMyLibrarySpaceID {
|
||||
if spec.SpaceID == wikiMyLibrarySpaceID {
|
||||
return d.
|
||||
Desc("2-step orchestration: resolve my_library -> list nodes").
|
||||
GET("/open-apis/wiki/v2/spaces/my_library").
|
||||
@@ -91,13 +83,17 @@ var WikiNodeList = common.Shortcut{
|
||||
Set("space_id", "<resolved_space_id>")
|
||||
}
|
||||
return d.
|
||||
GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spaceID))).
|
||||
GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spec.SpaceID))).
|
||||
Params(params).
|
||||
Set("space_id", spaceID)
|
||||
Set("space_id", spec.SpaceID)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
warnIfConflictingPagingFlags(runtime)
|
||||
spaceID := strings.TrimSpace(runtime.Str("space-id"))
|
||||
spec, err := readWikiNodeListSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spaceID := spec.SpaceID
|
||||
|
||||
// Resolve the my_library alias to the per-user real space_id before
|
||||
// listing, so the subsequent request hits a concrete space endpoint.
|
||||
@@ -110,7 +106,7 @@ var WikiNodeList = common.Shortcut{
|
||||
spaceID = resolved
|
||||
}
|
||||
|
||||
nodes, hasMore, nextToken, err := fetchWikiNodes(runtime, spaceID)
|
||||
nodes, hasMore, nextToken, err := fetchWikiNodes(runtime, spaceID, spec.ParentNodeToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -127,10 +123,104 @@ var WikiNodeList = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
func fetchWikiNodes(runtime *common.RuntimeContext, spaceID string) ([]map[string]interface{}, bool, string, error) {
|
||||
type wikiNodeListSpec struct {
|
||||
SpaceID string
|
||||
ParentNodeToken string
|
||||
}
|
||||
|
||||
func readWikiNodeListSpec(runtime *common.RuntimeContext) (wikiNodeListSpec, error) {
|
||||
spaceID := strings.TrimSpace(runtime.Str("space-id"))
|
||||
// my_library is a per-user personal-library alias; it has no meaning
|
||||
// for a tenant_access_token (--as bot), so reject early with a clear
|
||||
// hint instead of deferring to API-time errors. Matches the contract
|
||||
// used by +node-create and +move.
|
||||
if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID {
|
||||
return wikiNodeListSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity does not support --space-id my_library; use an explicit numeric --space-id").WithParam("--space-id")
|
||||
}
|
||||
if err := validateWikiNodeListSpaceID(spaceID); err != nil {
|
||||
return wikiNodeListSpec{}, err
|
||||
}
|
||||
|
||||
parentNodeToken, err := normalizeWikiNodeListParentToken(strings.TrimSpace(runtime.Str("parent-node-token")))
|
||||
if err != nil {
|
||||
return wikiNodeListSpec{}, err
|
||||
}
|
||||
return wikiNodeListSpec{SpaceID: spaceID, ParentNodeToken: parentNodeToken}, nil
|
||||
}
|
||||
|
||||
func validateWikiNodeListSpaceID(spaceID string) error {
|
||||
if spaceID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--space-id is required").WithParam("--space-id")
|
||||
}
|
||||
if spaceID == wikiMyLibrarySpaceID {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(spaceID, "://") || strings.ContainsAny(spaceID, "/?#") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--space-id must be a numeric wiki space_id, not a URL or path",
|
||||
).WithParam("--space-id").WithHint("Run `lark-cli wiki +space-list --as user` to discover space IDs.")
|
||||
}
|
||||
if !isDecimalWikiSpaceID(spaceID) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--space-id must be a numeric wiki space_id; do not pass a wiki node token, document token, or title",
|
||||
).WithParam("--space-id").WithHint("Run `lark-cli wiki +space-list --as user` to list accessible wiki spaces, then pass the numeric `space_id`.")
|
||||
}
|
||||
if err := validateOptionalResourceName(spaceID, "--space-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isDecimalWikiSpaceID(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeWikiNodeListParentToken(parentNodeToken string) (string, error) {
|
||||
if parentNodeToken == "" {
|
||||
return "", nil
|
||||
}
|
||||
if strings.Contains(parentNodeToken, "://") {
|
||||
ref, ok := common.ParseResourceURL(parentNodeToken)
|
||||
if !ok {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--parent-node-token URL is unsupported",
|
||||
).WithParam("--parent-node-token").WithHint("Pass a raw wiki node token from `wiki +node-get` or `wiki +node-list`.")
|
||||
}
|
||||
if ref.Type != "wiki" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--parent-node-token must identify a wiki node; got a %s URL",
|
||||
ref.Type,
|
||||
).WithParam("--parent-node-token").WithHint("Resolve the document URL with `lark-cli wiki +node-get --node-token <url>` and use its `node_token`.")
|
||||
}
|
||||
parentNodeToken = ref.Token
|
||||
}
|
||||
if strings.ContainsAny(parentNodeToken, "/?#") {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--parent-node-token must be a raw wiki node token, not a partial URL or path",
|
||||
).WithParam("--parent-node-token")
|
||||
}
|
||||
if !looksLikeWikiNodeToken(parentNodeToken) {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--parent-node-token must be a wiki node token; do not pass a docx/sheet/base/file token",
|
||||
).WithParam("--parent-node-token").WithHint("Run `lark-cli wiki +node-get --node-token <url-or-token>` to resolve a document URL or obj_token to the wiki `node_token` first.")
|
||||
}
|
||||
if err := validateOptionalResourceName(parentNodeToken, "--parent-node-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parentNodeToken, nil
|
||||
}
|
||||
|
||||
func fetchWikiNodes(runtime *common.RuntimeContext, spaceID, parentNodeToken string) ([]map[string]interface{}, bool, string, error) {
|
||||
pageSize := runtime.Int("page-size")
|
||||
startToken := strings.TrimSpace(runtime.Str("page-token"))
|
||||
parentNodeToken := strings.TrimSpace(runtime.Str("parent-node-token"))
|
||||
auto := wikiListShouldAutoPaginate(runtime)
|
||||
pageLimit := runtime.Int("page-limit")
|
||||
|
||||
@@ -153,7 +243,7 @@ func fetchWikiNodes(runtime *common.RuntimeContext, spaceID string) ([]map[strin
|
||||
}
|
||||
data, err := runtime.CallAPITyped("GET", apiPath, params, nil)
|
||||
if err != nil {
|
||||
return nil, false, "", err
|
||||
return nil, false, "", wikiNodeListProblem(err, runtime)
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
for _, item := range items {
|
||||
@@ -177,6 +267,36 @@ func fetchWikiNodes(runtime *common.RuntimeContext, spaceID string) ([]map[strin
|
||||
return nodes, lastHasMore, lastPageToken, nil
|
||||
}
|
||||
|
||||
func wikiNodeListProblem(err error, runtime *common.RuntimeContext) error {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
switch p.Code {
|
||||
case 131002:
|
||||
msg := strings.ToLower(p.Message)
|
||||
switch {
|
||||
case strings.Contains(msg, "page_token"):
|
||||
appendWikiProblemHint(err, "The page token is invalid or stale. Use only the `page_token` returned by the immediately preceding `wiki +node-list` response, or omit --page-token and start over.")
|
||||
case strings.Contains(msg, "space_id"):
|
||||
appendWikiProblemHint(err, "The --space-id value must be the numeric wiki space_id from `wiki +space-list`; do not pass a wiki URL, node token, document token, or title.")
|
||||
default:
|
||||
appendWikiProblemHint(err, "Check the wiki +node-list flags. Fix the parameter before retrying; this is not a transient error.")
|
||||
}
|
||||
case 131005:
|
||||
appendWikiProblemHint(err, "The target wiki space or parent node was not found. Re-discover the space with `wiki +space-list` and the parent with `wiki +node-list`/`wiki +node-get`; do not retry the same stale token.")
|
||||
case 131006:
|
||||
if runtime != nil && runtime.As().IsBot() {
|
||||
appendWikiProblemHint(err, "The bot/app identity cannot read this wiki space or node. Grant the app the required wiki scope and ensure the app or bot has access to the target knowledge space.")
|
||||
} else {
|
||||
appendWikiProblemHint(err, "The current user cannot read this wiki space or node. Switch to a user with access or ask the space owner to grant read permission.")
|
||||
}
|
||||
case 99991400:
|
||||
appendWikiProblemHint(err, "Rate limited by the wiki API. Stop immediate retries and retry later with exponential backoff or a smaller --page-limit.")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func wikiNodeListItem(m map[string]interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"space_id": common.GetString(m, "space_id"),
|
||||
|
||||
@@ -37,7 +37,6 @@ metadata:
|
||||
- 用户要在云空间(云盘/云存储)里新建文件夹,优先使用 `lark-cli drive +create-folder`。
|
||||
- 用户要查看某个文件有哪些可下载预览格式,或想下载 PDF / HTML / 文本 / 图片等预览产物,使用 `lark-cli drive +preview`。
|
||||
- 用户要获取某个文件的封面图,优先使用 `lark-cli drive +cover`;先 `--list-only` 看规格,再选 `--spec` 下载。
|
||||
- 用户要导出云文档时,优先使用 `lark-cli drive +export --url '<文档 URL>' --file-extension <格式>`;URL 会自动解析类型和 token,Wiki URL 会自动解析到底层 `obj_token/obj_type`。只有手里只有裸 token 时才使用 `--token <TOKEN> --doc-type <doc|docx|sheet|bitable|slides|wiki>`;裸 Wiki token 推荐显式传 `--doc-type wiki`,CLI 会先解析 Wiki node;如果误按底层类型传参且 export task 返回 `file token invalid`,CLI 也会尝试按 Wiki node token 解析并重试一次。
|
||||
- 用户要把本地文件上传到知识库 / 文档库里的某个 wiki 节点下时,仍然使用 `lark-cli drive +upload --wiki-token <wiki_token>`;不要误切到 `wiki` 域命令。
|
||||
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`。
|
||||
- 用户给的是 wiki URL / token,且后续还没明确底层资源类型时,先用 `lark-cli drive +inspect` 解包;`+inspect` 失败后不要自动切到别的写接口继续尝试,先按错误提示处理权限、scope 或链接问题。
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
把 `doc` / `docx` / `sheet` / `bitable` / `slides`(也支持 Wiki URL / Wiki node token 自动解包)导出到本地文件。这个 shortcut 内置有限轮询:
|
||||
把 `doc` / `docx` / `sheet` / `bitable` / `slides` 导出到本地文件。这个 shortcut 内置有限轮询:
|
||||
|
||||
- 如果导出任务在轮询窗口内完成,会直接下载到本地目录
|
||||
- 如果轮询结束仍未完成,会返回 `ticket`、`ready=false`、`timed_out=true` 和 `next_command`
|
||||
@@ -13,29 +13,6 @@
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 推荐:直接传 URL,CLI 自动解析类型和 token
|
||||
lark-cli drive +export \
|
||||
--url "https://example.feishu.cn/docx/<DOCX_TOKEN>" \
|
||||
--file-extension pdf
|
||||
|
||||
# Wiki URL 也推荐直接传,CLI 会先解析到底层 obj_token/obj_type
|
||||
lark-cli drive +export \
|
||||
--url "https://example.feishu.cn/wiki/<WIKI_NODE_TOKEN>" \
|
||||
--file-extension pdf
|
||||
|
||||
# 只有裸 Wiki node token 时,显式传 --doc-type wiki,让 CLI 先解析到底层文档类型
|
||||
lark-cli drive +export \
|
||||
--token "<WIKI_NODE_TOKEN>" \
|
||||
--doc-type wiki \
|
||||
--file-extension pdf
|
||||
|
||||
# 兼容兜底:如果误把 Wiki token 搭配底层类型传入,且 export task 返回 file token invalid,
|
||||
# CLI 会尝试按 Wiki node token 解析并重试一次;但新脚本仍推荐上面的 --doc-type wiki
|
||||
lark-cli drive +export \
|
||||
--token "<WIKI_NODE_TOKEN>" \
|
||||
--doc-type docx \
|
||||
--file-extension pdf
|
||||
|
||||
# 导出新版文档为 pdf,默认保存到当前目录
|
||||
lark-cli drive +export \
|
||||
--token "<DOCX_TOKEN>" \
|
||||
@@ -119,9 +96,8 @@ lark-cli drive +export \
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 与 `--token` 二选一 | 源文档 URL,推荐优先使用;CLI 自动解析类型和 token,Wiki URL 会解析到底层 `obj_token/obj_type` |
|
||||
| `--token` | 与 `--url` 二选一 | 源文档裸 token;裸 token 必须同时传 `--doc-type`。裸 Wiki node token 推荐传 `--doc-type wiki`,CLI 会先解析到底层 `obj_token/obj_type`;如误传底层类型并触发 export task `file token invalid`,CLI 会尝试按 Wiki node token 解析并重试一次 |
|
||||
| `--doc-type` | 条件必填 | 源文档类型:`doc` / `docx` / `sheet` / `bitable` / `slides` / `wiki`;仅当使用裸 `--token` 时必填,使用 `--url` 时自动推断。`wiki` 只用于裸 Wiki node token,解析后会按真实底层类型发起导出 |
|
||||
| `--token` | 是 | 源文档 token |
|
||||
| `--doc-type` | 是 | 源文档类型:`doc` / `docx` / `sheet` / `bitable` / `slides` |
|
||||
| `--file-extension` | 是 | 导出格式:`docx` / `pdf` / `xlsx` / `csv` / `markdown` / `base` / `pptx` |
|
||||
| `--sub-id` | 条件必填 | 当 `sheet` / `bitable` 导出为 `csv` 时必填 |
|
||||
| `--only-schema` | 否 | 仅当 `--doc-type bitable --file-extension base` 时可用;只导出多维表格结构,不导出记录数据 |
|
||||
@@ -131,17 +107,12 @@ lark-cli drive +export \
|
||||
|
||||
## 关键约束
|
||||
|
||||
- 推荐优先传 `--url`,不要从 URL 手工拆 token 和 type;尤其是 Wiki URL,CLI 会自动解包到底层资源
|
||||
- `--url` 和 `--token` 互斥
|
||||
- 裸 `--token` 必须传 `--doc-type`;裸 Wiki node token 使用 `--doc-type wiki`
|
||||
- `doc` 支持导出为 `docx` / `pdf`
|
||||
- `docx` 支持导出为 `docx` / `pdf` / `markdown`
|
||||
- `sheet` 支持导出为 `xlsx` / `csv` / `pdf`
|
||||
- `bitable` 支持导出为 `xlsx` / `csv` / `base` / `pdf`
|
||||
- `slides` 支持导出为 `pptx` / `pdf`
|
||||
- `csv` 只支持 `sheet` / `bitable`,且必须带 `--sub-id`
|
||||
- `markdown` 只支持 `docx`
|
||||
- `base` 只支持 `bitable`
|
||||
- `--only-schema` 只支持 `bitable` 导出为 `.base`,用于仅导出表结构
|
||||
- 如果格式不匹配,CLI 会返回 typed validation error,并在 `hint` 中给出可重试的 `--file-extension` 建议;例如 `docx + csv` 会提示改用 `docx/pdf/markdown`,或改传 sheet/bitable URL
|
||||
- `pptx` 只支持 `slides`
|
||||
- `slides` 支持导出为 `pptx` / `pdf`
|
||||
- `sheet` / `bitable` 导出为 `csv` 时必须带 `--sub-id`
|
||||
- shortcut 内部固定有限轮询:最多 10 次,每次间隔 5 秒
|
||||
- 轮询超时不是失败;会返回 `ticket`、`timed_out=true` 和 `next_command`,供后续继续查询
|
||||
|
||||
@@ -150,7 +121,8 @@ lark-cli drive +export \
|
||||
```bash
|
||||
# 第一步:先尝试直接导出
|
||||
lark-cli drive +export \
|
||||
--url "<DOCX_URL>" \
|
||||
--token "<DOCX_TOKEN>" \
|
||||
--doc-type docx \
|
||||
--file-extension pdf \
|
||||
--file-name "weekly-report.pdf"
|
||||
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
| `summary.skipped` | 因 `--if-exists=skip` 或 `--if-exists=smart` 命中“无需传输”而跳过的文件数 |
|
||||
| `summary.failed` | 上传 / 覆盖 / 建目录 / 删除失败的条目数;**只要不为 0,命令就以非零状态退出**(结构化 `items[]` 仍在 stdout 上) |
|
||||
| `summary.deleted_remote` | 启用 `--delete-remote --yes` 时删除的云端文件数 |
|
||||
| `items[]` | 每个条目的明细(`rel_path` / `file_token` / `action` / 覆盖时的 `version` / `size_bytes` / 失败时的 `error`) |
|
||||
| `summary.aborted` | 命中终止性错误并停止后续批处理时为 `true` |
|
||||
| `items[]` | 每个条目的明细(`rel_path` / `file_token` / `action` / 覆盖时的 `version` / `size_bytes` / 失败时的 `error` / `hint` / `phase` / `error_class` / `code` / `subtype` / `retryable`) |
|
||||
|
||||
`items[].action` 取值:`uploaded` / `overwritten` / `skipped` / `folder_created` / `deleted_remote` / `failed` / `delete_failed`。
|
||||
`items[].action` 取值:`uploaded` / `overwritten` / `skipped` / `folder_created` / `deleted_remote` / `already_deleted` / `failed` / `delete_failed`。
|
||||
|
||||
> 本地目录(包括空目录)会被镜像到 Drive;新建的子目录会以 `action: "folder_created"` 出现在 `items[]` 里,但**不计入** `summary.uploaded`(该字段只数文件)。已存在的远端目录复用其 token,不会重复 `create_folder`,也不会出现在 `items[]` 里。
|
||||
|
||||
@@ -95,6 +96,7 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
- `--delete-remote`(无 `--yes`)→ Validate 直接报错:`--delete-remote requires --yes`,不会发起任何列表 / 上传 / 删除请求。
|
||||
- `--delete-remote --yes` → Validate 阶段还会**动态做一次** `space:document:delete` 的 scope 预检:缺这条 scope 时整次运行立刻失败、不发任何上传请求,避免出现"上传都成功了,但删除阶段才报 missing_scope"的半同步状态。
|
||||
- `--delete-remote --yes`(且 scope 已授权)→ 正常执行:先把本地文件 push 上去,再扫一遍远端 `type=file` 列表,把不在本地清单里的逐个删除。**任何上传 / 覆盖 / 建目录失败时,整段 `--delete-remote` 阶段会被跳过**(stderr 上有提示),命令以非零状态退出,远端不会被破坏。
|
||||
- 删除阶段如果服务端返回 `1061007 file has been delete`,说明目标远端文件在本次 DELETE 前已经不存在;这已经满足 `--delete-remote` 的目标状态,输出会记为 `action: "already_deleted"`,不计入 `summary.failed`,也不计入 `summary.deleted_remote`。
|
||||
- 远端同名冲突且使用默认 `fail`,或冲突里混有 folder / 其他非 `type=file` 对象 → 在上传阶段前失败,删除阶段不会运行。
|
||||
- 不传 `--delete-remote` → `summary.deleted_remote` 永远是 0;命令对远端"多余"文件视而不见。
|
||||
- 在线文档(docx / sheet / bitable / ...)和快捷方式即使本地完全没有同名文件,也**不会**进入删除候选,因为它们从来不进 `summary.uploaded` 的对齐域。
|
||||
@@ -110,22 +112,46 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
"uploaded": 0,
|
||||
"skipped": 0,
|
||||
"failed": 0,
|
||||
"deleted_remote": 0
|
||||
"deleted_remote": 0,
|
||||
"aborted": false
|
||||
},
|
||||
"items": [
|
||||
{"rel_path": "...", "file_token": "...", "action": "folder_created"},
|
||||
{"rel_path": "...", "file_token": "...", "action": "uploaded", "size_bytes": 0},
|
||||
{"rel_path": "...", "file_token": "...", "action": "overwritten", "version": "...", "size_bytes": 0},
|
||||
{"rel_path": "...", "file_token": "...", "action": "skipped", "size_bytes": 0},
|
||||
{"rel_path": "...", "action": "failed", "size_bytes": 0, "error": "..."},
|
||||
{"rel_path": "...", "action": "failed", "size_bytes": 0, "error": "...", "hint": "...", "phase": "upload", "error_class": "...", "code": 0, "subtype": "...", "retryable": false},
|
||||
{"rel_path": "...", "file_token": "...", "action": "deleted_remote"},
|
||||
{"rel_path": "...", "file_token": "...", "action": "delete_failed", "error": "..."}
|
||||
{"rel_path": "...", "file_token": "...", "action": "already_deleted"},
|
||||
{"rel_path": "...", "file_token": "...", "action": "delete_failed", "error": "...", "hint": "...", "phase": "delete", "error_class": "...", "code": 0, "subtype": "...", "retryable": false}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`rel_path` 始终用 `/` 作为分隔符(跨平台一致)。
|
||||
|
||||
## 失败处理与 agent 行为
|
||||
|
||||
`+push` 的失败项带结构化字段,agent 必须优先读 `items[].error_class` / `phase` / `code`,不要只看自然语言 `error` 文本。`summary.aborted=true` 表示命令已经遇到终止性错误并停止后续批处理;这时**不要原样重试**,先修复根因。
|
||||
|
||||
常见终止性错误:
|
||||
|
||||
| `error_class` | 常见 `code` | 含义 | Agent 应对 |
|
||||
|---|---:|---|---|
|
||||
| `app_scope_missing` | `99991672` | 应用身份缺少 Drive / 文件夹相关 scope | 停止重试,引导开通错误里列出的应用身份权限,例如 `space:folder:create` 或 `drive:drive` |
|
||||
| `user_scope_missing` | `99991679` | 用户身份缺少授权 | 停止重试,走 `lark-cli auth login --scope ...` 补错误里列出的 scope |
|
||||
| `permission_denied` | `1061004` / HTTP 403 | 当前身份无权操作目标资源 | 停止重试,检查目标文件夹权限、身份类型(user / bot)和资源可见性 |
|
||||
| `invalid_api_parameters` | `1061002` | API 参数被服务端拒绝 | 停止重试,检查 `--folder-token`、覆盖模式、`file_token`、文件名和上传参数;不要对同一参数组合批量重试 |
|
||||
| `parent_node_missing` | `1061044` | 上传 / 建目录使用的父文件夹不存在或当前身份不可见 | 停止重试,检查 `--folder-token` 是否仍存在、是否有权限、父目录是否在 push 过程中被删除;不要继续上传同一目录树 |
|
||||
| `rate_limited` | `99991400` | 触发频控 | 停止当前批次,退避后再重试 |
|
||||
| `server_error` | `1061001` / `2200` | Drive 服务端异常 | 停止当前批次,稍后重试;保留 `log_id` 便于排查 |
|
||||
|
||||
非终止但需要解释的状态:
|
||||
|
||||
- `file_size_limit` / `1061043`:文件超过 Drive 上传限制。不要继续尝试同一文件;改拆分或换存储方式。
|
||||
- `upload_size_mismatch` / `1062009`:本地文件在上传过程中发生变化,或声明大小与实际读取大小不一致。重新扫描本地文件后再 push。
|
||||
- `remote_not_found` / `1061007`:一般表示远端文件已不存在。删除阶段的 `1061007` 会被视为 `already_deleted` 成功项;其他阶段需重新列表确认远端状态。
|
||||
|
||||
## 性能注意
|
||||
|
||||
- 默认 `skip` 下,已存在的远端文件一律不碰;`overwrite` 下,重复跑会重传所有命中的同名文件;`smart` 下会按 `modified_time` 跳过已对齐的远端文件,但对“远端更旧”的文件仍会进入覆盖路径,因此它减少的是**不必要的重传**,不是把覆盖风险完全拿掉。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: lark-markdown
|
||||
version: 1.2.1
|
||||
version: 1.2.2
|
||||
description: "飞书 Markdown:查看、创建、上传、编辑和比较 Markdown 文件。当用户需要创建或编辑 Markdown 文件、读取、修改、局部 patch 或比较差异时使用。不负责将 Markdown 导入为飞书在线文档,也不负责文件搜索、权限、评论、移动、删除等云空间管理操作。"
|
||||
metadata:
|
||||
requires:
|
||||
@@ -25,7 +25,8 @@ metadata:
|
||||
- 用户要先拿 Markdown 文件的历史版本号,再做比较/下载/回滚,先用 [`lark-drive`](../lark-drive/SKILL.md) 的 `lark-cli drive +version-history`
|
||||
- 用户要把本地 Markdown **导入成在线新版文档(docx)**,不要用本 skill,改用 [`lark-drive`](../lark-drive/SKILL.md) 的 `lark-cli drive +import --type docx`
|
||||
- 用户要对 Markdown 文件做**rename / move / delete / 搜索 / 权限 / 评论**等云空间(云盘/云存储)操作,不要留在本 skill,切到 [`lark-drive`](../lark-drive/SKILL.md)
|
||||
- `markdown +create` / `+overwrite` 命中 `missing scope`、`permission denied`、`not found`、`version limit` 时,默认停止重试并按报错 hint 处理;只有 `rate limit` 或临时网络错误才做有限重试。
|
||||
- `markdown +create` / `+overwrite` 命中 `missing scope`、`permission denied`、`not found`、`quota_exceeded`、`version limit` 时,默认停止重试并按报错 hint 处理;只有 `rate_limit`、`server_error` 或临时网络错误才做有限退避重试。
|
||||
- `markdown +create` 的目标参数不要猜:Drive 文件夹用 `--folder-token`,Wiki 节点用 `--wiki-token`。如果用户给的是 URL,可以直接传完整 URL;CLI 会归一成 token。不要把 doc/sheet/wiki URL 放进 `--folder-token` 试错。
|
||||
|
||||
## 核心边界
|
||||
|
||||
|
||||
@@ -32,11 +32,21 @@ lark-cli markdown +create \
|
||||
--folder-token fldcn_xxx \
|
||||
--file ./README.md
|
||||
|
||||
# 创建到指定文件夹(可直接传 Drive folder URL)
|
||||
lark-cli markdown +create \
|
||||
--folder-token "https://feishu.cn/drive/folder/fldcn_xxx" \
|
||||
--file ./README.md
|
||||
|
||||
# 创建到指定 wiki 节点
|
||||
lark-cli markdown +create \
|
||||
--wiki-token wikcn_xxx \
|
||||
--file ./README.md
|
||||
|
||||
# 创建到指定 wiki 节点(可直接传 wiki URL)
|
||||
lark-cli markdown +create \
|
||||
--wiki-token "https://feishu.cn/wiki/wikcn_xxx" \
|
||||
--file ./README.md
|
||||
|
||||
# 预览底层请求
|
||||
lark-cli markdown +create \
|
||||
--name README.md \
|
||||
@@ -48,8 +58,8 @@ lark-cli markdown +create \
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--folder-token` | 否 | 目标 Drive 文件夹 token;与 `--wiki-token` 互斥;省略时创建到根目录 |
|
||||
| `--wiki-token` | 否 | 目标 wiki 节点 token;与 `--folder-token` 互斥;传入后自动映射为 `parent_type=wiki` |
|
||||
| `--folder-token` | 否 | 目标 Drive 文件夹 token 或 Drive folder URL;与 `--wiki-token` 互斥;省略时创建到根目录 |
|
||||
| `--wiki-token` | 否 | 目标 wiki 节点 token 或 wiki URL;与 `--folder-token` 互斥;传入后自动映射为 `parent_type=wiki` |
|
||||
| `--name` | 条件必填 | 文件名,**必须显式带 `.md` 后缀**;使用 `--content` 时必填;使用 `--file` 时可省略,默认取本地文件名 |
|
||||
| `--content` | 条件必填 | Markdown 内容;与 `--file` 互斥;支持直接传字符串、`@file`、`-`(stdin) |
|
||||
| `--file` | 条件必填 | 本地 `.md` 文件路径;与 `--content` 互斥 |
|
||||
@@ -58,6 +68,8 @@ lark-cli markdown +create \
|
||||
|
||||
- `--content` 与 `--file` 必须二选一
|
||||
- `--folder-token` 与 `--wiki-token` 互斥
|
||||
- `--folder-token` 只能是 Drive 文件夹;不要传 wiki/doc/sheet/base/file token 或 URL
|
||||
- `--wiki-token` 只能是 Wiki 节点;如果只有 docx/sheet/base 等文档 URL,先用 `lark-cli wiki +node-get --node-token <url>` 解析出 `node_token`
|
||||
- `--name` 必须带 `.md` 后缀
|
||||
- `--file` 指向的本地文件名也必须带 `.md` 后缀
|
||||
- 传 `--wiki-token` 时,返回值中不会附带 `/file/<token>` URL,因为 wiki 承载文件没有稳定的独立 file URL
|
||||
@@ -88,6 +100,14 @@ lark-cli markdown +create \
|
||||
>
|
||||
> **不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。
|
||||
|
||||
## 失败处理
|
||||
|
||||
- `not_found` / `1061044`:父目录或 wiki 节点不存在,或 token 类型放错参数。修正 `--folder-token` / `--wiki-token` 后再试,不要重复提交同一参数。
|
||||
- `quota_exceeded` / `1061101`:目标存储空间配额已满。释放空间、换父目录/节点或请管理员扩容后再试。
|
||||
- `permission_denied` / `missing_scope`:区分身份处理。`--as user` 看用户授权和目标 ACL;`--as bot` 看应用 scope 与目标目录/节点 ACL。
|
||||
- `rate_limit`:停止立即重试,使用退避。
|
||||
- `server_error` / `233523001`:可以稍后有限重试;若重复出现,保留 `log_id` / request id 给服务端排查。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-markdown](../SKILL.md) — Markdown 域总览
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: lark-wiki
|
||||
version: 1.0.1
|
||||
version: 1.0.2
|
||||
description: "飞书知识库:管理知识空间、空间成员和文档节点。创建和查询知识空间、查看和管理空间成员、管理节点层级结构、在知识库中组织文档和快捷方式。当用户需要在知识库中查找或创建文档、浏览知识空间结构、查看或管理空间成员、移动或复制节点时使用。当用户给出 doubao.com 的 /wiki/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。不负责:上传文件到知识库节点下(走 lark-drive)、编辑文档/表格/Base 内容(走 lark-doc / lark-sheets / lark-base)。"
|
||||
metadata:
|
||||
requires:
|
||||
@@ -34,6 +34,8 @@ metadata:
|
||||
- 用户明确选定后再执行 `lark-cli wiki +delete-space --space-id <ID> --yes`(高风险写操作,必须显式 `--yes`)。
|
||||
- 反例:不要把 wiki URL / 名称直接当 `--space-id`(如 `--space-id "https://.../wiki/<wiki_token>"`);务必先用 `wiki spaces get_node` 解析出 `data.node.space_id` 再传。
|
||||
- 用户要在知识库中创建新节点,优先使用 `lark-cli wiki +node-create`。
|
||||
- 用户要列出 Wiki 节点:先用 `wiki +space-list --as user` 拿数字 `space_id`,再用 `wiki +node-list --space-id <space_id>`。不要把 wiki URL、node token、doc token、名称直接当 `--space-id`。钻子节点时 `--parent-node-token` 必须是 wiki node token;如果用户给的是 docx/sheet/base URL,先用 `wiki +node-get --node-token <url>` 解析出 `node_token`。
|
||||
- `wiki +node-list` 命中 `invalid_parameters`、`not_found`、`permission_denied` 时,不要重复调用同一参数;按 hint 修 `space_id` / `parent_node_token` / 权限。只有 `rate_limit` 才做退避重试。
|
||||
- 用户说“给知识库添加成员/管理员”:先把目标解析成“用户 / 群 / 部门 / 应用”四类之一,再决定 `--member-type`,不要先调 `wiki +member-add` 再根据报错反推类型。
|
||||
- 用户说“部门 + bot”:这是已知不支持路径。不要继续尝试 `wiki +member-add --as bot`;直接提示必须改成 `--as user`,或明确告知当前要求无法完成。
|
||||
- 用户说“用户 / 群 / 应用 + 添加成员”:先解析对应 ID,再执行 `wiki +member-add`。
|
||||
|
||||
@@ -11,6 +11,9 @@ lark-cli wiki +node-list --space-id <SPACE_ID>
|
||||
# Drill into a sub-directory (still single page by default)
|
||||
lark-cli wiki +node-list --space-id <SPACE_ID> --parent-node-token <NODE_TOKEN>
|
||||
|
||||
# Drill with a wiki URL (CLI normalizes /wiki/<token> to node_token)
|
||||
lark-cli wiki +node-list --space-id <SPACE_ID> --parent-node-token "https://feishu.cn/wiki/wikcn_xxx"
|
||||
|
||||
# Personal document library (user identity only)
|
||||
lark-cli wiki +node-list --space-id my_library --as user
|
||||
|
||||
@@ -31,8 +34,8 @@ lark-cli wiki +node-list --space-id <SPACE_ID> --format pretty
|
||||
|
||||
| Flag | Type | Required | Default | Description |
|
||||
|------|------|----------|---------|-------------|
|
||||
| `--space-id` | string | **Yes** | — | Wiki space ID. Use `my_library` for personal document library (user only) |
|
||||
| `--parent-node-token` | string | No | — | Parent node token; omit to list the space root |
|
||||
| `--space-id` | string | **Yes** | — | Numeric wiki space ID. Use `my_library` for personal document library (user only) |
|
||||
| `--parent-node-token` | string | No | — | Parent wiki node token, or a `/wiki/<token>` URL; omit to list the space root |
|
||||
| `--page-size` | int | No | 50 | Page size, 1-50 |
|
||||
| `--page-token` | string | No | — | Page cursor; implies single-page fetch (no auto-pagination) |
|
||||
| `--page-all` | bool | No | `false` | Automatically paginate through all pages (capped by `--page-limit`) |
|
||||
@@ -82,6 +85,10 @@ lark-cli wiki +node-list --space-id 6946843325487912356 --parent-node-token wikc
|
||||
## Notes
|
||||
|
||||
- `--space-id my_library` is a per-user alias and only valid with `--as user`. The shortcut will refuse `--as bot` with `my_library` upfront.
|
||||
- `--space-id` is a numeric wiki `space_id`. Do not pass a wiki URL, wiki node token, document token, or title. Use `lark-cli wiki +space-list --as user` to discover it.
|
||||
- `--parent-node-token` must resolve to a wiki node token. If you have a docx/sheet/base/file URL, first run `lark-cli wiki +node-get --node-token <url>` and use the returned `node_token`.
|
||||
- Treat `invalid_parameters` (`space_id is not int`, `invalid page_token`), `not_found` (`node not found by parent node token`), and `permission_denied` as terminal for the current arguments. Fix the argument or permission before retrying.
|
||||
- For `rate_limit`, stop immediate retries and retry later with exponential backoff or a smaller `--page-limit`.
|
||||
|
||||
## Required Scope
|
||||
|
||||
|
||||
166
tests/cli_e2e/application/slash_command_dryrun_test.go
Normal file
166
tests/cli_e2e/application/slash_command_dryrun_test.go
Normal 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)
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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",
|
||||
@@ -14,7 +14,7 @@
|
||||
- TestDriveAddCommentDryRun_File / TestDriveAddCommentDryRun_Base: dry-run coverage for `drive +add-comment` on supported Drive file and Base targets; pins the `metas.batch_query -> files/:token/new_comments` file chain, Base `file_type=bitable`, and Base anchor fields.
|
||||
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for the same path, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`.
|
||||
- TestDrive_SecureLabelDryRun: dry-run coverage for `drive +secure-label-list` and `drive +secure-label-update`; asserts label-list query params and update URL→type inference, request method/URL/type query, and `label-id` body shape. Runs without hitting live APIs because update can trigger document-level security approval flows.
|
||||
- TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask / TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask / TestDriveExportDryRun_MarkdownFetchAPI / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, Wiki URL and `--doc-type wiki` token `get_node -> export_tasks` planning, markdown fetch request shape without docs fetch `extra_param`, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs.
|
||||
- TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_MarkdownFetchAPI / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, markdown fetch request shape without docs fetch `extra_param`, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs.
|
||||
- TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary.
|
||||
- TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary.
|
||||
- Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered.
|
||||
@@ -29,7 +29,7 @@
|
||||
| ✓ | drive +apply-permission | shortcut | drive_apply_permission_dryrun_test.go::TestDrive_ApplyPermissionDryRun | `--token` URL vs bare; `--type` (enum) with URL inference; `--perm view\|edit`; `--remark` optional | dry-run only; no live-apply E2E because a real request pushes a card to the owner |
|
||||
| ✕ | drive +delete | shortcut | | none | no primary delete workflow yet |
|
||||
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |
|
||||
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask + TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask + TestDriveExportDryRun_MarkdownFetchAPI + TestDriveExportDryRun_BitableBaseOnlySchema | `--url`; `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema`; Wiki URL / `--doc-type wiki` resolve step; markdown fetch omits docs fetch `extra_param` | dry-run only; no live export workflow yet |
|
||||
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_MarkdownFetchAPI + TestDriveExportDryRun_BitableBaseOnlySchema | `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema`; markdown fetch omits docs fetch `extra_param` | dry-run only; no live export workflow yet |
|
||||
| ✕ | drive +export-download | shortcut | | none | no export-download workflow yet |
|
||||
| ✕ | drive +import | shortcut | | none | no import workflow yet |
|
||||
| ✕ | drive +move | shortcut | | none | no move workflow yet |
|
||||
|
||||
@@ -61,96 +61,6 @@ func TestDriveExportDryRun_FileNameMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask(t *testing.T) {
|
||||
setDriveDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+export",
|
||||
"--url", "https://example.feishu.cn/wiki/wikiDryRunExport",
|
||||
"--file-extension", "pdf",
|
||||
"--file-name", "wiki-report",
|
||||
"--output-dir", "./exports",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("api.0.method=%q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/wiki/v2/spaces/get_node" {
|
||||
t.Fatalf("api.0.url=%q, want wiki get_node\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.params.token").String(); got != "wikiDryRunExport" {
|
||||
t.Fatalf("api.0.params.token=%q, want wiki token\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.method").String(); got != "POST" {
|
||||
t.Fatalf("api.1.method=%q, want POST\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.url").String(); got != "/open-apis/drive/v1/export_tasks" {
|
||||
t.Fatalf("api.1.url=%q, want export_tasks\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.body.token").String(); got != "obj_token_from_step_0" {
|
||||
t.Fatalf("api.1.body.token=%q, want resolved token placeholder\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.body.type").String(); got != "obj_type_from_step_0" {
|
||||
t.Fatalf("api.1.body.type=%q, want resolved type placeholder\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "wiki_token").String(); got != "wikiDryRunExport" {
|
||||
t.Fatalf("wiki_token=%q, want source wiki token\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "file_name").String(); got != "wiki-report.pdf" {
|
||||
t.Fatalf("file_name=%q, want wiki-report.pdf\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask(t *testing.T) {
|
||||
setDriveDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+export",
|
||||
"--token", "wikiDryRunExport",
|
||||
"--doc-type", "wiki",
|
||||
"--file-extension", "pdf",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("api.0.method=%q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/wiki/v2/spaces/get_node" {
|
||||
t.Fatalf("api.0.url=%q, want wiki get_node\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.params.token").String(); got != "wikiDryRunExport" {
|
||||
t.Fatalf("api.0.params.token=%q, want wiki token\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.body.token").String(); got != "obj_token_from_step_0" {
|
||||
t.Fatalf("api.1.body.token=%q, want resolved token placeholder\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.body.type").String(); got != "obj_type_from_step_0" {
|
||||
t.Fatalf("api.1.body.type=%q, want resolved type placeholder\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "wiki_token").String(); got != "wikiDryRunExport" {
|
||||
t.Fatalf("wiki_token=%q, want source wiki token\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportDryRun_MarkdownFetchAPI(t *testing.T) {
|
||||
setDriveDryRunConfigEnv(t)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user