mirror of
https://github.com/larksuite/cli.git
synced 2026-07-10 11:04:26 +08:00
Compare commits
8 Commits
ci/plugin-
...
feat/lark-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38d54119cd | ||
|
|
95fd8b3a5e | ||
|
|
cefa041696 | ||
|
|
9ea42d1bc6 | ||
|
|
d134da8536 | ||
|
|
84ac346b74 | ||
|
|
f3017c291e | ||
|
|
f151ca9ac1 |
92
.github/workflows/ci.yml
vendored
92
.github/workflows/ci.yml
vendored
@@ -47,34 +47,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
plugin-integration:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
# No fetch_meta: the git-archive clean tree must embed only the
|
||||
# committed meta_data stub (reproduces the bare-module customer state).
|
||||
- name: Run plugin-integration L4 tests
|
||||
run: go test -count=1 -timeout=15m ./tests/plugin_e2e/...
|
||||
|
||||
sidecar-integration:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Run sidecar tag build + HMAC round-trip
|
||||
run: make sidecar-test
|
||||
|
||||
# ── Layer 2: Quality Gate ──────────────────────────────────────────
|
||||
unit-test:
|
||||
needs: fast-gate
|
||||
@@ -291,19 +263,13 @@ 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:
|
||||
@@ -311,28 +277,7 @@ jobs:
|
||||
LARKSUITE_CLI_APP_ID: dry-run
|
||||
LARKSUITE_CLI_APP_SECRET: dry-run
|
||||
LARKSUITE_CLI_BRAND: feishu
|
||||
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
|
||||
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
|
||||
|
||||
e2e-live:
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
@@ -347,22 +292,15 @@ 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"
|
||||
@@ -372,24 +310,16 @@ 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: |
|
||||
if [ "$E2E_MODE" = "skip" ]; then
|
||||
echo "No live CLI E2E needed: $E2E_REASON"
|
||||
exit 0
|
||||
fi
|
||||
packages="$E2E_LIVE_PACKAGES"
|
||||
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
|
||||
if [ -z "$packages" ]; then
|
||||
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
|
||||
echo "No CLI E2E packages to test after exclusions."
|
||||
exit 1
|
||||
fi
|
||||
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
|
||||
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
|
||||
- name: Publish CLI E2E test report
|
||||
if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
if: ${{ !cancelled() }}
|
||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||
with:
|
||||
name: CLI E2E Tests
|
||||
@@ -442,7 +372,7 @@ jobs:
|
||||
# ── Results Gate (single required check for branch protection) ─────
|
||||
results:
|
||||
if: ${{ always() }}
|
||||
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header, plugin-integration, sidecar-integration]
|
||||
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Evaluate results
|
||||
@@ -462,18 +392,10 @@ jobs:
|
||||
echo "| L3 | e2e-live | ${{ needs.e2e-live.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | security | ${{ needs.security.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | license-header | ${{ needs.license-header.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | plugin-integration (observe-only) | ${{ needs.plugin-integration.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | sidecar-integration (observe-only) | ${{ needs.sidecar-integration.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Any failure or cancellation in any job blocks the merge.
|
||||
# Legitimately skipped jobs (deadcode on push, e2e-live on fork,
|
||||
# license-header on push) are OK.
|
||||
#
|
||||
# plugin-integration and sidecar-integration are intentionally NOT
|
||||
# in this loop yet: they run on every PR and their status is shown
|
||||
# in the table above, but a failure is observe-only (non-blocking)
|
||||
# during the initial soak. Add them back here to make them required
|
||||
# once they have proven stable.
|
||||
FAILED=0
|
||||
for result in \
|
||||
"${{ needs.fast-gate.result }}" \
|
||||
|
||||
28
CHANGELOG.md
28
CHANGELOG.md
@@ -2,33 +2,6 @@
|
||||
|
||||
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
|
||||
@@ -1398,7 +1371,6 @@ 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
|
||||
|
||||
12
Makefile
12
Makefile
@@ -23,7 +23,7 @@ PREFIX ?= /usr/local
|
||||
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
|
||||
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
|
||||
|
||||
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test
|
||||
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks
|
||||
|
||||
all: test
|
||||
|
||||
@@ -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/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
|
||||
$(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
|
||||
|
||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||
unit-test: fetch_meta
|
||||
@@ -105,14 +105,6 @@ uninstall:
|
||||
clean:
|
||||
rm -f $(BINARY)
|
||||
|
||||
# sidecar-test compiles and runs the authsidecar* build-tagged code that the
|
||||
# default CI matrix never sees (they carry //go:build tags).
|
||||
sidecar-test:
|
||||
go build -tags authsidecar -o /dev/null .
|
||||
go test $(RACE_FLAG) -count=1 -tags authsidecar ./extension/credential/sidecar/ ./extension/transport/sidecar/ ./internal/cmdutil/
|
||||
go test $(RACE_FLAG) -count=1 -tags authsidecar_demo ./sidecar/server-demo/
|
||||
go test $(RACE_FLAG) -count=1 -tags authsidecar ./tests/sidecar_e2e/
|
||||
|
||||
# Run secret-leak checks locally before pushing.
|
||||
# Step 1: check-doc-tokens catches realistic-looking example tokens in reference
|
||||
# docs and asks you to use _EXAMPLE_TOKEN placeholders instead.
|
||||
|
||||
@@ -14,13 +14,11 @@ 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"
|
||||
@@ -105,11 +103,6 @@ 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
|
||||
@@ -120,11 +113,7 @@ func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Facto
|
||||
}
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
if catalog != nil {
|
||||
service.RegisterServiceCommandsFromCatalog(context.Background(), rootCmd, f, *catalog)
|
||||
} else {
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
}
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
@@ -132,29 +121,6 @@ func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Facto
|
||||
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, "")
|
||||
@@ -389,11 +355,10 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
|
||||
|
||||
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
||||
catalog := strictModeFixtureCatalog()
|
||||
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--dry-run",
|
||||
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
|
||||
})
|
||||
|
||||
if code != output.ExitValidation {
|
||||
|
||||
@@ -86,10 +86,13 @@ func symArrow() string {
|
||||
|
||||
// UpdateOptions holds inputs for the update command.
|
||||
type UpdateOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
Force bool
|
||||
Check bool
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
Force bool
|
||||
Check bool
|
||||
SkillsLayout string
|
||||
FlatSkills string
|
||||
FlatSet bool
|
||||
}
|
||||
|
||||
// NewCmdUpdate creates the update command.
|
||||
@@ -109,6 +112,7 @@ Detects the installation method automatically:
|
||||
Use --json for structured output (for AI agents and scripts).
|
||||
Use --check to only check for updates without installing.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.FlatSet = cmd.Flags().Changed("flat-skills")
|
||||
return updateRun(opts)
|
||||
},
|
||||
}
|
||||
@@ -116,6 +120,8 @@ Use --check to only check for updates without installing.`,
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmd.Flags().BoolVar(&opts.Force, "force", false, "force reinstall even if already up to date")
|
||||
cmd.Flags().BoolVar(&opts.Check, "check", false, "only check for updates, do not install")
|
||||
cmd.Flags().StringVar(&opts.SkillsLayout, "skills-layout", "", "skills layout: separate or hybrid")
|
||||
cmd.Flags().StringVar(&opts.FlatSkills, "flat-skills", "", "comma-separated skills kept as top-level skills when the effective layout is hybrid")
|
||||
cmdutil.SetRisk(cmd, "high-risk-write")
|
||||
|
||||
return cmd
|
||||
@@ -123,6 +129,9 @@ Use --check to only check for updates without installing.`,
|
||||
|
||||
func updateRun(opts *UpdateOptions) error {
|
||||
io := opts.Factory.IOStreams
|
||||
if err := validateSkillsLayoutOptions(opts); err != nil {
|
||||
return reportError(opts, io, "validation_error", err)
|
||||
}
|
||||
cur := currentVersion()
|
||||
updater := newUpdater()
|
||||
|
||||
@@ -148,7 +157,7 @@ func updateRun(opts *UpdateOptions) error {
|
||||
if !opts.Force && !update.IsNewer(latest, cur) {
|
||||
var skillsResult *skillscheck.SyncResult
|
||||
if !opts.Check {
|
||||
skillsResult = runSkillsAndState(updater, io, cur, opts.Force)
|
||||
skillsResult = runSkillsAndState(updater, io, cur, opts.Force, opts.SkillsLayout, opts.FlatSkills, opts.FlatSet)
|
||||
}
|
||||
return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
|
||||
}
|
||||
@@ -209,7 +218,7 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s
|
||||
}
|
||||
|
||||
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
|
||||
skillsResult := runSkillsAndState(updater, io, cur, opts.Force)
|
||||
skillsResult := runSkillsAndState(updater, io, cur, opts.Force, opts.SkillsLayout, opts.FlatSkills, opts.FlatSet)
|
||||
|
||||
reason := detect.ManualReason()
|
||||
if opts.JSON {
|
||||
@@ -228,9 +237,9 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
|
||||
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
|
||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||
if detect.Method == selfupdate.InstallPnpm {
|
||||
fmt.Fprintf(io.ErrOut, "\nOr install via pnpm (note: skills will not be synced):\n pnpm add -g %s@%s\n pnpm dlx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||
fmt.Fprintf(io.ErrOut, "\nOr install via pnpm (note: skills will not be synced):\n pnpm add -g %s@%s\n", selfupdate.NpmPackage, latest)
|
||||
} else {
|
||||
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n", selfupdate.NpmPackage, latest)
|
||||
}
|
||||
emitSkillsTextHints(io, skillsResult)
|
||||
return nil
|
||||
@@ -299,7 +308,7 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
|
||||
skillsResult := runSkillsAndState(updater, io, latest, opts.Force)
|
||||
skillsResult := runSkillsAndState(updater, io, latest, opts.Force, opts.SkillsLayout, opts.FlatSkills, opts.FlatSet)
|
||||
|
||||
if opts.JSON {
|
||||
result := map[string]interface{}{
|
||||
@@ -341,21 +350,25 @@ func verificationFailureHint(updater *selfupdate.Updater, latest, pm string) str
|
||||
return "the previous version has been restored"
|
||||
}
|
||||
if pm == "pnpm" {
|
||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): pnpm add -g %s@%s && pnpm dlx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): pnpm add -g %s@%s, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||
}
|
||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||
}
|
||||
|
||||
func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool) *skillscheck.SyncResult {
|
||||
if !force {
|
||||
if existing, ok := skillscheck.ReadSyncedVersion(); ok && normalizeVersion(existing) == normalizeVersion(stateVersion) {
|
||||
func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool, requestedLayout, requestedFlat string, flatSet bool) *skillscheck.SyncResult {
|
||||
layout, flat := resolveSkillsSyncOptions(requestedLayout, requestedFlat, flatSet)
|
||||
layoutExplicit := strings.TrimSpace(requestedLayout) != ""
|
||||
if !force && !layoutExplicit && !flatSet {
|
||||
if existing, existingLayout, ok := skillscheck.ReadSyncedVersionAndLayout(); ok && existingLayout != "" && normalizeVersion(existing) == normalizeVersion(stateVersion) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
result := syncSkills(skillscheck.SyncOptions{
|
||||
Version: stateVersion,
|
||||
Force: force,
|
||||
Runner: updater,
|
||||
Version: stateVersion,
|
||||
Layout: layout,
|
||||
FlatSkills: flat,
|
||||
Force: force,
|
||||
Runner: updater,
|
||||
})
|
||||
if result.Err != nil && strings.Contains(result.Err.Error(), "state not written") {
|
||||
fmt.Fprintf(io.ErrOut, "warning: %v\n", result.Err)
|
||||
@@ -363,6 +376,38 @@ func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, state
|
||||
return result
|
||||
}
|
||||
|
||||
func validateSkillsLayoutOptions(opts *UpdateOptions) errs.TypedError {
|
||||
if _, ok := skillscheck.NormalizeLayout(opts.SkillsLayout); !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--skills-layout must be one of separate or hybrid").WithParam("--skills-layout")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveSkillsSyncOptions(requestedLayout, requestedFlat string, flatSet bool) (string, []string) {
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil {
|
||||
readable = false
|
||||
state = nil
|
||||
}
|
||||
|
||||
layout := skillscheck.LayoutSeparate
|
||||
if strings.TrimSpace(requestedLayout) != "" {
|
||||
layout, _ = skillscheck.NormalizeLayout(requestedLayout)
|
||||
} else if readable && state != nil {
|
||||
if stateLayout, ok := skillscheck.NormalizeLayout(state.Layout); ok && state.Layout != "" {
|
||||
layout = stateLayout
|
||||
}
|
||||
}
|
||||
|
||||
if flatSet {
|
||||
return layout, skillscheck.ParseFlatSkills(requestedFlat)
|
||||
}
|
||||
if readable && state != nil {
|
||||
return layout, state.FlatSkills
|
||||
}
|
||||
return layout, []string{}
|
||||
}
|
||||
|
||||
// reportAlreadyUpToDate emits the JSON / pretty output for the
|
||||
// already-up-to-date branch, including any skills_action / skills_warning
|
||||
// fields derived from skillsResult. When check is true, this is the pure
|
||||
@@ -409,6 +454,12 @@ func applySkillsStatus(env map[string]interface{}, target string) {
|
||||
if len(state.SkippedDeletedSkills) > 0 {
|
||||
status["skipped_deleted"] = state.SkippedDeletedSkills
|
||||
}
|
||||
if state.Layout != "" {
|
||||
status["layout"] = state.Layout
|
||||
}
|
||||
if len(state.FlatSkills) > 0 {
|
||||
status["flat_skills"] = state.FlatSkills
|
||||
}
|
||||
env["skills_status"] = status
|
||||
}
|
||||
|
||||
@@ -419,6 +470,7 @@ func applySkillsResult(env map[string]interface{}, r *skillscheck.SyncResult) {
|
||||
case r.Err != nil:
|
||||
env["skills_action"] = "failed"
|
||||
env["skills_warning"] = fmt.Sprintf("skills update failed: %s", r.Err)
|
||||
env["skills_hint"] = skillsFailureHint()
|
||||
env["skills_summary"] = skillsSummary(r)
|
||||
default:
|
||||
env["skills_action"] = "synced"
|
||||
@@ -432,10 +484,17 @@ func skillsSummary(r *skillscheck.SyncResult) map[string]interface{} {
|
||||
"updated": len(r.Updated),
|
||||
"added": len(r.Added),
|
||||
"skipped_deleted": len(r.SkippedDeleted),
|
||||
"layout": r.Layout,
|
||||
}
|
||||
if len(r.Failed) > 0 {
|
||||
summary["failed"] = r.Failed
|
||||
}
|
||||
if len(r.Collected) > 0 {
|
||||
summary["collected"] = r.Collected
|
||||
}
|
||||
if len(r.Flat) > 0 {
|
||||
summary["flat"] = r.Flat
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
@@ -447,13 +506,17 @@ func emitSkillsTextHints(io *cmdutil.IOStreams, r *skillscheck.SyncResult) {
|
||||
if len(r.Failed) > 0 {
|
||||
fmt.Fprintf(io.ErrOut, " Failed skills: %s\n", strings.Join(r.Failed, ", "))
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, " To retry all official skills: lark-cli update --force\n")
|
||||
fmt.Fprintf(io.ErrOut, " %s\n", skillsFailureHint())
|
||||
case r.Force:
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated: restored all %d official skills\n", symOK(), len(r.Official))
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated: restored all %d official skills (%s layout)\n", symOK(), len(r.Official), r.Layout)
|
||||
default:
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated: %d official, %d updated, %d added, %d skipped because deleted locally\n", symOK(), len(r.Official), len(r.Updated), len(r.Added), len(r.SkippedDeleted))
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated: %d official, %d updated, %d added, %d skipped because deleted locally (%s layout)\n", symOK(), len(r.Official), len(r.Updated), len(r.Added), len(r.SkippedDeleted), r.Layout)
|
||||
if len(r.SkippedDeleted) > 0 {
|
||||
fmt.Fprintf(io.ErrOut, " To restore all official skills: lark-cli update --force\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func skillsFailureHint() string {
|
||||
return "Retry: lark-cli update --force. To switch to separate top-level skills: lark-cli update --skills-layout separate (this saves layout=separate and clears saved flat_skills)."
|
||||
}
|
||||
|
||||
@@ -204,6 +204,9 @@ func TestUpdatePnpm_Unavailable_ManualFallback(t *testing.T) {
|
||||
if !strings.Contains(out, "pnpm add -g") {
|
||||
t.Errorf("expected pnpm add -g hint, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "pnpm dlx skills add larksuite/cli") {
|
||||
t.Errorf("should not suggest standalone pnpm skills sync command, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeVersion(t *testing.T) {
|
||||
@@ -728,8 +731,8 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
|
||||
if !strings.Contains(out, "skills will not be synced") {
|
||||
t.Errorf("expected skills-not-synced warning in rollback hint, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "npx skills add larksuite/cli -y -g") {
|
||||
t.Errorf("expected npx skills add hint for skills sync, got: %s", out)
|
||||
if strings.Contains(out, "npx skills add larksuite/cli -y -g") {
|
||||
t.Errorf("should not suggest standalone skills sync command, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1133,7 +1136,7 @@ func newTestIO() *cmdutil.IOStreams {
|
||||
|
||||
func TestRunSkillsAndState_DedupHit(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21", Layout: skillscheck.LayoutSeparate}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := false
|
||||
@@ -1143,7 +1146,7 @@ func TestRunSkillsAndState_DedupHit(t *testing.T) {
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, "", "", false)
|
||||
if got != nil {
|
||||
t.Errorf("runSkillsAndState() = %+v, want nil for dedup hit", got)
|
||||
}
|
||||
@@ -1152,6 +1155,27 @@ func TestRunSkillsAndState_DedupHit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndState_MissingLayoutDoesNotDedup(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := false
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
called = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
},
|
||||
}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, "", "", false)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want successful sync when state lacks layout", got)
|
||||
}
|
||||
if !called {
|
||||
t.Error("SkillsCommandOverride not called, want resync when state lacks layout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
||||
@@ -1164,7 +1188,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
return successfulSkillsCommand()(args...)
|
||||
},
|
||||
}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", true)
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", true, "", "", false)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndState(force=true) = %+v, want successful result", got)
|
||||
}
|
||||
@@ -1176,7 +1200,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, "", "", false)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got)
|
||||
}
|
||||
@@ -1187,6 +1211,12 @@ func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
|
||||
if state.Version != "1.0.21" {
|
||||
t.Errorf("state.Version = %q, want \"1.0.21\"", state.Version)
|
||||
}
|
||||
if state.Layout != skillscheck.LayoutSeparate {
|
||||
t.Errorf("state.Layout = %q, want %q", state.Layout, skillscheck.LayoutSeparate)
|
||||
}
|
||||
if len(state.FlatSkills) != 0 {
|
||||
t.Errorf("state.FlatSkills = %#v, want empty", state.FlatSkills)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
|
||||
@@ -1201,7 +1231,7 @@ func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
|
||||
return r
|
||||
},
|
||||
}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, "", "", false)
|
||||
if got == nil || got.Err == nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with non-nil Err", got)
|
||||
}
|
||||
@@ -1214,6 +1244,50 @@ func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSkillsSyncOptions_UsesStateAsFallback(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{
|
||||
Version: "1.0.21",
|
||||
Layout: skillscheck.LayoutHybrid,
|
||||
FlatSkills: []string{"lark-doc"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
layout, flat := resolveSkillsSyncOptions("", "", false)
|
||||
if layout != skillscheck.LayoutHybrid {
|
||||
t.Fatalf("layout = %q, want %q", layout, skillscheck.LayoutHybrid)
|
||||
}
|
||||
if len(flat) != 1 || flat[0] != "lark-doc" {
|
||||
t.Fatalf("flat = %#v, want [lark-doc]", flat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSkillsLayoutOptionsRejectsSuiteMode(t *testing.T) {
|
||||
err := validateSkillsLayoutOptions(&UpdateOptions{SkillsLayout: "suite"})
|
||||
if err == nil {
|
||||
t.Fatal("validateSkillsLayoutOptions() err = nil, want validation error")
|
||||
}
|
||||
var validation *errs.ValidationError
|
||||
if !errors.As(err, &validation) {
|
||||
t.Fatalf("errors.As(err, *ValidationError) = false for %T", err)
|
||||
}
|
||||
if validation.Param != "--skills-layout" {
|
||||
t.Fatalf("validation.Param = %q, want --skills-layout", validation.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSkillsLayoutOptionsAllowsFlatSkillsFilteringAfterOfficialDiscovery(t *testing.T) {
|
||||
err := validateSkillsLayoutOptions(&UpdateOptions{
|
||||
SkillsLayout: skillscheck.LayoutHybrid,
|
||||
FlatSkills: "lark-shared,lark-unknown",
|
||||
FlatSet: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("validateSkillsLayoutOptions() err = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
long := strings.Repeat("x", 3000)
|
||||
got := selfupdate.Truncate(long, 2000)
|
||||
@@ -1494,7 +1568,7 @@ func TestRunSkillsAndState_StateWriteFailureWarns(t *testing.T) {
|
||||
t.Cleanup(func() { syncSkills = origSync })
|
||||
|
||||
f, _, stderr := newTestFactory(t)
|
||||
got := runSkillsAndState(&selfupdate.Updater{}, f.IOStreams, "1.0.21", false)
|
||||
got := runSkillsAndState(&selfupdate.Updater{}, f.IOStreams, "1.0.21", false, "", "", false)
|
||||
if got == nil || got.Err == nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with write error", got)
|
||||
}
|
||||
|
||||
@@ -10,22 +10,20 @@ 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)
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(driveCodeMeta, "drive") }
|
||||
|
||||
@@ -114,35 +114,8 @@ 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) {
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
// 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") }
|
||||
@@ -50,6 +50,8 @@ const (
|
||||
var (
|
||||
skillsIndexFetchTimeout = 10 * time.Second
|
||||
officialSkillsIndexURL = "https://open.feishu.cn/.well-known/skills/index.json"
|
||||
isolatedSkillsSourceURL = "https://open.feishu.cn/lark-cli/isolated-skills"
|
||||
isolatedSkillsFallback = "larksuite/cli/isolated-skills"
|
||||
)
|
||||
|
||||
// DetectResult holds installation detection results.
|
||||
@@ -328,6 +330,14 @@ func (u *Updater) InstallAllSkills() *NpmResult {
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) InstallSuiteSkill() *NpmResult {
|
||||
r := u.runSkillsInstall(isolatedSkillsSourceURL, []string{"lark-suite"})
|
||||
if r.Err != nil {
|
||||
r = u.runSkillsInstall(isolatedSkillsFallback, []string{"lark-suite"})
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) runSkillsAdd(source string) *NpmResult {
|
||||
return u.runSkillsCommand("-y", "skills", "add", source, "-g", "-y")
|
||||
}
|
||||
|
||||
@@ -208,6 +208,13 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
|
||||
},
|
||||
want: "-y skills add https://open.feishu.cn -s lark-mail -g -y",
|
||||
},
|
||||
{
|
||||
name: "install isolated suite skill",
|
||||
run: func(u *Updater) *NpmResult {
|
||||
return u.runSkillsInstall(isolatedSkillsSourceURL, []string{"lark-suite"})
|
||||
},
|
||||
want: "-y skills add https://open.feishu.cn/lark-cli/isolated-skills -s lark-suite -g -y",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -238,6 +245,76 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallSuiteSkillFallsBackToIsolatedGitHubSource(t *testing.T) {
|
||||
called := []string{}
|
||||
u := &Updater{
|
||||
SkillsCommandOverride: func(args ...string) *NpmResult {
|
||||
called = append(called, strings.Join(args, " "))
|
||||
r := &NpmResult{}
|
||||
if strings.Contains(strings.Join(args, " "), isolatedSkillsSourceURL) {
|
||||
r.Err = fmt.Errorf("isolated source unavailable")
|
||||
}
|
||||
return r
|
||||
},
|
||||
}
|
||||
|
||||
result := u.InstallSuiteSkill()
|
||||
if result.Err != nil {
|
||||
t.Fatalf("InstallSuiteSkill() err = %v, want nil", result.Err)
|
||||
}
|
||||
if len(called) != 2 {
|
||||
t.Fatalf("calls = %#v, want primary and fallback", called)
|
||||
}
|
||||
if !strings.Contains(called[0], isolatedSkillsSourceURL) {
|
||||
t.Fatalf("primary call = %q, want isolated source", called[0])
|
||||
}
|
||||
if !strings.Contains(called[1], isolatedSkillsFallback) {
|
||||
t.Fatalf("fallback call = %q, want isolated GitHub fallback", called[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallSuiteSkillUsesPnpmDlxForIsolatedSources(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses a POSIX shell script")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
logPath := filepath.Join(dir, "pnpm.log")
|
||||
script := filepath.Join(dir, "pnpm")
|
||||
scriptContent := fmt.Sprintf(`#!/bin/sh
|
||||
printf '%%s\n' "$*" >> %q
|
||||
case "$*" in
|
||||
*%q*) exit 1 ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
`, logPath, isolatedSkillsSourceURL)
|
||||
if err := os.WriteFile(script, []byte(scriptContent), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
|
||||
u := New()
|
||||
u.DetectOverride = func() DetectResult {
|
||||
return DetectResult{Method: InstallPnpm, PnpmAvailable: true}
|
||||
}
|
||||
|
||||
result := u.InstallSuiteSkill()
|
||||
if result.Err != nil {
|
||||
t.Fatalf("InstallSuiteSkill() err = %v, want nil", result.Err)
|
||||
}
|
||||
raw, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := strings.Split(strings.TrimSpace(string(raw)), "\n")
|
||||
want := []string{
|
||||
"dlx skills add " + isolatedSkillsSourceURL + " -s lark-suite -g -y",
|
||||
"dlx skills add " + isolatedSkillsFallback + " -s lark-suite -g -y",
|
||||
}
|
||||
if strings.Join(got, "\n") != strings.Join(want, "\n") {
|
||||
t.Fatalf("pnpm calls = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsIndexSuccess(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `{"skills":[{"name":"lark-calendar"}]}`)
|
||||
|
||||
389
internal/skillscheck/layout.go
Normal file
389
internal/skillscheck/layout.go
Normal file
@@ -0,0 +1,389 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
const (
|
||||
LayoutSeparate = "separate"
|
||||
LayoutHybrid = "hybrid"
|
||||
|
||||
suiteSkillName = "lark-suite"
|
||||
sharedSkillName = "lark-shared"
|
||||
suiteRoutesPlaceholder = "<!-- LARK_SUITE_ROUTES -->"
|
||||
)
|
||||
|
||||
type GlobalSkillInfo struct {
|
||||
Name string
|
||||
Path string
|
||||
}
|
||||
|
||||
func NormalizeLayout(layout string) (string, bool) {
|
||||
switch strings.TrimSpace(layout) {
|
||||
case "", LayoutSeparate:
|
||||
return LayoutSeparate, true
|
||||
case LayoutHybrid:
|
||||
return LayoutHybrid, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func ParseFlatSkills(value string) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
name := strings.TrimSpace(part)
|
||||
if name != "" {
|
||||
seen[name] = true
|
||||
}
|
||||
}
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
func ParseGlobalSkillInfosJSON(text string) []GlobalSkillInfo {
|
||||
infos, _ := parseGlobalSkillInfosJSON(text)
|
||||
return infos
|
||||
}
|
||||
|
||||
func parseGlobalSkillInfosJSON(text string) ([]GlobalSkillInfo, bool) {
|
||||
type globalSkill struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
var skills []globalSkill
|
||||
if err := json.Unmarshal([]byte(text), &skills); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
seen := map[string]GlobalSkillInfo{}
|
||||
for _, skill := range skills {
|
||||
name := strings.TrimSpace(skill.Name)
|
||||
path := strings.TrimSpace(skill.Path)
|
||||
if name == "" || path == "" || !skillNamePattern.MatchString(name) {
|
||||
continue
|
||||
}
|
||||
seen[name] = GlobalSkillInfo{Name: name, Path: path}
|
||||
}
|
||||
|
||||
out := make([]GlobalSkillInfo, 0, len(seen))
|
||||
for _, info := range seen {
|
||||
out = append(out, info)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, true
|
||||
}
|
||||
|
||||
func installedSkillNamesFromInfos(infos []GlobalSkillInfo) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, info := range infos {
|
||||
seen[info.Name] = true
|
||||
if info.Name == suiteSkillName {
|
||||
for _, subskill := range listSuiteSubskills(info.Path) {
|
||||
seen[subskill] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
func listSuiteSubskills(suitePath string) []string {
|
||||
entries, err := vfs.ReadDir(filepath.Join(suitePath, "references", "subskills"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(entry.Name())
|
||||
if name != "" && skillNamePattern.MatchString(name) {
|
||||
seen[name] = true
|
||||
}
|
||||
}
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
func normalOfficialSkills(skills []string) []string {
|
||||
out := []string{}
|
||||
for _, skill := range uniqueSorted(skills) {
|
||||
if skill != suiteSkillName {
|
||||
out = append(out, skill)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func deletedOfficialSkills(official, local []string, previous *SkillsState, stateReadable, force bool, layout string) []string {
|
||||
if force || !stateReadable || previous == nil {
|
||||
return []string{}
|
||||
}
|
||||
officialSet := toSet(official)
|
||||
localSet := toSet(local)
|
||||
deleted := map[string]bool{}
|
||||
for _, skill := range previous.OfficialSkills {
|
||||
if !officialSet[skill] || localSet[skill] {
|
||||
continue
|
||||
}
|
||||
if layout != LayoutSeparate && skill == sharedSkillName {
|
||||
continue
|
||||
}
|
||||
deleted[skill] = true
|
||||
}
|
||||
return sortedKeys(deleted)
|
||||
}
|
||||
|
||||
func suiteEffectiveSkills(official []string, deleted map[string]bool) []string {
|
||||
out := []string{}
|
||||
for _, skill := range normalOfficialSkills(official) {
|
||||
if !deleted[skill] {
|
||||
out = append(out, skill)
|
||||
}
|
||||
}
|
||||
return uniqueSorted(out)
|
||||
}
|
||||
|
||||
func resolveHybridSkillSets(layout string, requestedFlat, official []string, skippedDeleted []string) ([]string, []string, error) {
|
||||
if layout == LayoutSeparate {
|
||||
return []string{}, []string{}, nil
|
||||
}
|
||||
|
||||
officialSet := toSet(official)
|
||||
deletedSet := toSet(skippedDeleted)
|
||||
configuredFlat := map[string]bool{}
|
||||
effectiveFlat := map[string]bool{}
|
||||
for _, skill := range uniqueSorted(requestedFlat) {
|
||||
if skill == sharedSkillName {
|
||||
continue
|
||||
}
|
||||
if !officialSet[skill] {
|
||||
continue
|
||||
}
|
||||
configuredFlat[skill] = true
|
||||
if !deletedSet[skill] {
|
||||
effectiveFlat[skill] = true
|
||||
}
|
||||
}
|
||||
|
||||
collected := []string{}
|
||||
for _, skill := range normalOfficialSkills(official) {
|
||||
if skill == sharedSkillName {
|
||||
collected = append(collected, skill)
|
||||
continue
|
||||
}
|
||||
if deletedSet[skill] || effectiveFlat[skill] {
|
||||
continue
|
||||
}
|
||||
collected = append(collected, skill)
|
||||
}
|
||||
return sortedKeys(configuredFlat), uniqueSortedWithFirst(collected, sharedSkillName), nil
|
||||
}
|
||||
|
||||
func uniqueSortedWithFirst(values []string, first string) []string {
|
||||
seen := toSet(values)
|
||||
if !seen[first] {
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
delete(seen, first)
|
||||
return append([]string{first}, sortedKeys(seen)...)
|
||||
}
|
||||
|
||||
func assembleSuiteLayout(layout string, collected []string, keepSharedTopLevel bool, infos []GlobalSkillInfo) error {
|
||||
if layout == LayoutSeparate {
|
||||
return nil
|
||||
}
|
||||
|
||||
infoByName := map[string]GlobalSkillInfo{}
|
||||
for _, info := range infos {
|
||||
infoByName[info.Name] = info
|
||||
}
|
||||
suiteInfo, ok := infoByName[suiteSkillName]
|
||||
if !ok {
|
||||
return fmt.Errorf("%s was not installed from isolated skills source", suiteSkillName)
|
||||
}
|
||||
|
||||
subskillsDir := filepath.Join(suiteInfo.Path, "references", "subskills")
|
||||
if err := vfs.RemoveAll(subskillsDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vfs.MkdirAll(subskillsDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, skill := range collected {
|
||||
info, ok := infoByName[skill]
|
||||
if !ok {
|
||||
return fmt.Errorf("suite subskill %q was not installed", skill)
|
||||
}
|
||||
dst := filepath.Join(subskillsDir, skill)
|
||||
if keepSharedTopLevel && skill == sharedSkillName {
|
||||
if err := copyDir(info.Path, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := moveDir(info.Path, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return renderSuiteRoutes(suiteInfo.Path, collected)
|
||||
}
|
||||
|
||||
func renderSuiteRoutes(suitePath string, collected []string) error {
|
||||
skillPath := filepath.Join(suitePath, "SKILL.md")
|
||||
data, err := vfs.ReadFile(skillPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
text := normalizeSuiteTemplateText(string(data))
|
||||
routes := []string{}
|
||||
for _, skill := range collected {
|
||||
desc := skillDescription(filepath.Join(suitePath, "references", "subskills", skill, "SKILL.md"))
|
||||
if desc == "" {
|
||||
desc = skill
|
||||
}
|
||||
routes = append(routes, fmt.Sprintf("- %s: %s", skill, desc))
|
||||
}
|
||||
if !strings.Contains(text, suiteRoutesPlaceholder) {
|
||||
return fmt.Errorf("%s route placeholder not found", suiteSkillName)
|
||||
}
|
||||
text = strings.Replace(text, suiteRoutesPlaceholder, strings.Join(routes, "\n"), 1)
|
||||
return vfs.WriteFile(skillPath, []byte(text), 0o644)
|
||||
}
|
||||
|
||||
func normalizeSuiteTemplateText(text string) string {
|
||||
text = strings.ReplaceAll(text, "--collected-skills", "--flat-skills")
|
||||
oldShared := "`lark-shared` 是共享基础能力,不作为 `--flat-skills` 的可选项。为了保证 suite 内子能力可用,hybrid 布局会同时保留顶层 `lark-shared`,并在 `lark-suite/references/subskills/lark-shared/SKILL.md` 中维护一份副本。"
|
||||
newShared := "`lark-shared` 是共享基础能力,不作为 `--flat-skills` 的可选项。为了保证 suite 内子能力可用,它始终会进入 `lark-suite/references/subskills/lark-shared/SKILL.md`;只有 hybrid 布局存在平铺 skill 时,顶层才会额外保留一份 `lark-shared`。"
|
||||
return strings.ReplaceAll(text, oldShared, newShared)
|
||||
}
|
||||
|
||||
func skillDescription(path string) string {
|
||||
data, err := vfs.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
|
||||
return ""
|
||||
}
|
||||
for i, line := range lines[1:] {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "---" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "description:") {
|
||||
value := strings.TrimSpace(strings.TrimPrefix(trimmed, "description:"))
|
||||
if value == ">" || value == "|" {
|
||||
return foldedYAMLScalar(lines[i+2:])
|
||||
}
|
||||
return strings.Trim(value, `"'`)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func foldedYAMLScalar(lines []string) string {
|
||||
parts := []string{}
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if trimmed == "---" || !isIndentedYAMLLine(line) {
|
||||
break
|
||||
}
|
||||
parts = append(parts, trimmed)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func isIndentedYAMLLine(line string) bool {
|
||||
return strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")
|
||||
}
|
||||
|
||||
func moveDir(src, dst string) error {
|
||||
if err := vfs.RemoveAll(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vfs.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vfs.Rename(src, dst); err == nil {
|
||||
return nil
|
||||
}
|
||||
if err := copyDir(src, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
return vfs.RemoveAll(src)
|
||||
}
|
||||
|
||||
func copyDir(src, dst string) error {
|
||||
info, err := vfs.Stat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("%s is not a directory", src)
|
||||
}
|
||||
if err := vfs.RemoveAll(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
return copyDirEntries(src, dst)
|
||||
}
|
||||
|
||||
func copyDirEntries(src, dst string) error {
|
||||
if err := vfs.MkdirAll(dst, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err := vfs.ReadDir(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
srcPath := filepath.Join(src, entry.Name())
|
||||
dstPath := filepath.Join(dst, entry.Name())
|
||||
if entry.IsDir() {
|
||||
if err := copyDirEntries(srcPath, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := copyFile(srcPath, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := vfs.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
if err := vfs.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := vfs.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
_ = out.Close()
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
@@ -23,10 +23,12 @@ var ErrUnreadableState = errors.New("skills state is unreadable")
|
||||
|
||||
type SkillsState struct {
|
||||
Version string `json:"version"`
|
||||
Layout string `json:"layout,omitempty"`
|
||||
OfficialSkills []string `json:"official_skills"`
|
||||
UpdatedSkills []string `json:"updated_skills"`
|
||||
AddedOfficialSkills []string `json:"added_official_skills"`
|
||||
SkippedDeletedSkills []string `json:"skipped_deleted_skills"`
|
||||
FlatSkills []string `json:"flat_skills"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -76,6 +78,14 @@ func ReadSyncedVersion() (string, bool) {
|
||||
return state.Version, true
|
||||
}
|
||||
|
||||
func ReadSyncedVersionAndLayout() (version string, layout string, ok bool) {
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable || state.Version == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return state.Version, state.Layout, true
|
||||
}
|
||||
|
||||
func (s *SkillsState) ensureNonNilSlices() {
|
||||
if s.OfficialSkills == nil {
|
||||
s.OfficialSkills = []string{}
|
||||
@@ -89,4 +99,7 @@ func (s *SkillsState) ensureNonNilSlices() {
|
||||
if s.SkippedDeletedSkills == nil {
|
||||
s.SkippedDeletedSkills = []string{}
|
||||
}
|
||||
if s.FlatSkills == nil {
|
||||
s.FlatSkills = []string{}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,10 +32,12 @@ func TestReadState_Valid(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
want := SkillsState{
|
||||
Version: "1.2.3",
|
||||
Layout: LayoutHybrid,
|
||||
OfficialSkills: []string{"lark-doc", "lark-im"},
|
||||
UpdatedSkills: []string{"lark-doc"},
|
||||
AddedOfficialSkills: []string{"lark-task"},
|
||||
SkippedDeletedSkills: []string{"custom-skill"},
|
||||
FlatSkills: []string{"lark-doc"},
|
||||
UpdatedAt: "2026-05-18T10:00:00Z",
|
||||
}
|
||||
data, err := json.Marshal(want)
|
||||
@@ -115,6 +117,9 @@ func TestWriteState_CreatesDirAndWritesState(t *testing.T) {
|
||||
if got.SkippedDeletedSkills == nil {
|
||||
t.Fatal("skipped_deleted_skills decoded as nil, want empty slice")
|
||||
}
|
||||
if got.FlatSkills == nil {
|
||||
t.Fatal("flat_skills decoded as nil, want empty slice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSyncedVersionFromState(t *testing.T) {
|
||||
@@ -137,3 +142,24 @@ func TestReadSyncedVersionFromState(t *testing.T) {
|
||||
t.Fatalf("ReadSyncedVersion() = (%q, %v), want (\"\", false) for empty version", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSyncedVersionAndLayoutFromState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
if version, layout, ok := ReadSyncedVersionAndLayout(); ok || version != "" || layout != "" {
|
||||
t.Fatalf("ReadSyncedVersionAndLayout() = (%q, %q, %v), want empty result for missing state", version, layout, ok)
|
||||
}
|
||||
if err := WriteState(SkillsState{Version: "1.2.3", Layout: LayoutHybrid}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if version, layout, ok := ReadSyncedVersionAndLayout(); !ok || version != "1.2.3" || layout != LayoutHybrid {
|
||||
t.Fatalf("ReadSyncedVersionAndLayout() = (%q, %q, %v), want (\"1.2.3\", %q, true)", version, layout, ok, LayoutHybrid)
|
||||
}
|
||||
if err := WriteState(SkillsState{Layout: LayoutHybrid}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if version, layout, ok := ReadSyncedVersionAndLayout(); ok || version != "" || layout != "" {
|
||||
t.Fatalf("ReadSyncedVersionAndLayout() = (%q, %q, %v), want empty result for empty version", version, layout, ok)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ var (
|
||||
|
||||
type SyncInput struct {
|
||||
Version string
|
||||
Layout string
|
||||
OfficialSkills []string
|
||||
LocalSkills []string
|
||||
PreviousState *SkillsState
|
||||
@@ -195,7 +196,18 @@ func parseOfficialSkillsList(lines []string) []string {
|
||||
}
|
||||
|
||||
func PlanSync(input SyncInput) SyncPlan {
|
||||
official := uniqueSorted(input.OfficialSkills)
|
||||
official := normalOfficialSkills(input.OfficialSkills)
|
||||
layout, _ := NormalizeLayout(input.Layout)
|
||||
skippedDeleted := deletedOfficialSkills(official, input.LocalSkills, input.PreviousState, input.StateReadable, input.Force, layout)
|
||||
if layout != LayoutSeparate {
|
||||
return SyncPlan{
|
||||
Version: input.Version,
|
||||
OfficialSkills: official,
|
||||
ToUpdate: suiteEffectiveSkills(official, toSet(skippedDeleted)),
|
||||
Added: newlyOfficialSkills(official, input.PreviousState, input.StateReadable),
|
||||
SkippedDeleted: skippedDeleted,
|
||||
}
|
||||
}
|
||||
if input.Force {
|
||||
return SyncPlan{
|
||||
Version: input.Version,
|
||||
@@ -208,19 +220,7 @@ func PlanSync(input SyncInput) SyncPlan {
|
||||
|
||||
officialSet := toSet(official)
|
||||
installedOfficial := intersection(input.LocalSkills, officialSet)
|
||||
|
||||
previousOfficial := []string{}
|
||||
if input.StateReadable && input.PreviousState != nil {
|
||||
previousOfficial = input.PreviousState.OfficialSkills
|
||||
}
|
||||
previousSet := toSet(previousOfficial)
|
||||
|
||||
newAddedOfficial := []string{}
|
||||
for _, skill := range official {
|
||||
if !previousSet[skill] {
|
||||
newAddedOfficial = append(newAddedOfficial, skill)
|
||||
}
|
||||
}
|
||||
newAddedOfficial := newlyOfficialSkills(official, input.PreviousState, input.StateReadable)
|
||||
|
||||
updateSet := toSet(installedOfficial)
|
||||
for _, skill := range newAddedOfficial {
|
||||
@@ -229,19 +229,12 @@ func PlanSync(input SyncInput) SyncPlan {
|
||||
toUpdate := sortedKeys(updateSet)
|
||||
updateSet = toSet(toUpdate)
|
||||
|
||||
skipped := []string{}
|
||||
for _, skill := range official {
|
||||
if !updateSet[skill] {
|
||||
skipped = append(skipped, skill)
|
||||
}
|
||||
}
|
||||
|
||||
return SyncPlan{
|
||||
Version: input.Version,
|
||||
OfficialSkills: official,
|
||||
ToUpdate: toUpdate,
|
||||
Added: uniqueSorted(newAddedOfficial),
|
||||
SkippedDeleted: skipped,
|
||||
Added: newAddedOfficial,
|
||||
SkippedDeleted: skippedDeleted,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,13 +245,16 @@ type SkillsRunner interface {
|
||||
ListGlobalSkills() *selfupdate.NpmResult
|
||||
InstallSkill(nameList []string) *selfupdate.NpmResult
|
||||
InstallAllSkills() *selfupdate.NpmResult
|
||||
InstallSuiteSkill() *selfupdate.NpmResult
|
||||
}
|
||||
|
||||
type SyncOptions struct {
|
||||
Version string
|
||||
Force bool
|
||||
Runner SkillsRunner
|
||||
Now func() time.Time
|
||||
Version string
|
||||
Layout string
|
||||
FlatSkills []string
|
||||
Force bool
|
||||
Runner SkillsRunner
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type SyncResult struct {
|
||||
@@ -271,6 +267,9 @@ type SyncResult struct {
|
||||
Err error
|
||||
Detail string
|
||||
Force bool
|
||||
Layout string
|
||||
Flat []string
|
||||
Collected []string
|
||||
}
|
||||
|
||||
func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
@@ -280,16 +279,26 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
if opts.Runner == nil {
|
||||
return &SyncResult{Action: "failed", Err: fmt.Errorf("skills runner is nil")}
|
||||
}
|
||||
layout, ok := NormalizeLayout(opts.Layout)
|
||||
if !ok {
|
||||
return &SyncResult{Action: "failed", Err: fmt.Errorf("unsupported skills layout %q", opts.Layout)}
|
||||
}
|
||||
|
||||
// --- Step 1: List official skills ---
|
||||
official, reason, ok := listOfficialSkills(opts.Runner)
|
||||
if !ok {
|
||||
if layout != LayoutSeparate {
|
||||
return failedSync(layout, opts.Force, fmt.Errorf("failed to discover official skills for %s layout: %s", layout, reason), reason)
|
||||
}
|
||||
return fallbackFullInstall(opts, reason, nil)
|
||||
}
|
||||
|
||||
// --- Step 2: List local (installed) skills ---
|
||||
local, ok := listLocalSkills(opts.Runner)
|
||||
if !ok {
|
||||
if layout != LayoutSeparate {
|
||||
return failedSync(layout, opts.Force, fmt.Errorf("failed to list local skills for %s layout", layout), "local skills list failed or parsed as empty")
|
||||
}
|
||||
return fallbackFullInstall(opts, "local skills list failed or parsed as empty", official)
|
||||
}
|
||||
|
||||
@@ -302,12 +311,17 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
|
||||
plan := PlanSync(SyncInput{
|
||||
Version: opts.Version,
|
||||
Layout: layout,
|
||||
OfficialSkills: official,
|
||||
LocalSkills: local,
|
||||
PreviousState: previous,
|
||||
StateReadable: readable,
|
||||
Force: opts.Force,
|
||||
})
|
||||
flat, collected, err := resolveHybridSkillSets(layout, opts.FlatSkills, plan.OfficialSkills, plan.SkippedDeleted)
|
||||
if err != nil {
|
||||
return &SyncResult{Action: "failed", Err: err, Official: plan.OfficialSkills, Force: opts.Force, Layout: layout}
|
||||
}
|
||||
|
||||
result := &SyncResult{
|
||||
Action: "synced",
|
||||
@@ -316,25 +330,59 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
Added: plan.Added,
|
||||
SkippedDeleted: plan.SkippedDeleted,
|
||||
Force: opts.Force,
|
||||
Layout: layout,
|
||||
Flat: flat,
|
||||
Collected: collected,
|
||||
}
|
||||
|
||||
if len(plan.ToUpdate) == 0 {
|
||||
if layout != LayoutSeparate {
|
||||
return failedSync(layout, opts.Force, fmt.Errorf("no target skills to assemble %s layout", layout), "toUpdate skills empty")
|
||||
}
|
||||
return fallbackFullInstall(opts, "toUpdate skills empty fallback", official)
|
||||
}
|
||||
|
||||
if len(plan.ToUpdate) > 0 {
|
||||
installResult := opts.Runner.InstallSkill(plan.ToUpdate)
|
||||
if installResult == nil || installResult.Err != nil {
|
||||
if layout != LayoutSeparate {
|
||||
return failedSync(layout, opts.Force, fmt.Errorf("failed to install skills for %s layout: %s", layout, resultDetail(installResult)), resultDetail(installResult))
|
||||
}
|
||||
return fallbackFullInstall(opts, resultDetail(installResult), official)
|
||||
}
|
||||
}
|
||||
if layout != LayoutSeparate {
|
||||
installSuiteResult := opts.Runner.InstallSuiteSkill()
|
||||
if installSuiteResult == nil || installSuiteResult.Err != nil {
|
||||
result.Action = "failed"
|
||||
result.Err = fmt.Errorf("failed to install %s from isolated skills source: %s", suiteSkillName, resultDetail(installSuiteResult))
|
||||
result.Detail = resultDetail(installSuiteResult)
|
||||
return result
|
||||
}
|
||||
infosResult := opts.Runner.ListGlobalSkillsJSON()
|
||||
if infosResult == nil || infosResult.Err != nil {
|
||||
result.Action = "failed"
|
||||
result.Err = fmt.Errorf("failed to list installed skills for %s assembly: %s", suiteSkillName, resultDetail(infosResult))
|
||||
result.Detail = resultDetail(infosResult)
|
||||
return result
|
||||
}
|
||||
infos := ParseGlobalSkillInfosJSON(infosResult.Stdout.String())
|
||||
keepSharedTopLevel := layout == LayoutHybrid && len(flat) > 0
|
||||
if err := assembleSuiteLayout(layout, collected, keepSharedTopLevel, infos); err != nil {
|
||||
result.Action = "failed"
|
||||
result.Err = fmt.Errorf("failed to assemble %s layout: %w", layout, err)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
state := SkillsState{
|
||||
Version: opts.Version,
|
||||
Layout: layout,
|
||||
OfficialSkills: plan.OfficialSkills,
|
||||
UpdatedSkills: plan.ToUpdate,
|
||||
AddedOfficialSkills: plan.Added,
|
||||
SkippedDeletedSkills: plan.SkippedDeleted,
|
||||
FlatSkills: stateFlatSkills(layout, flat),
|
||||
UpdatedAt: opts.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if err := WriteState(state); err != nil {
|
||||
@@ -346,6 +394,16 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
return result
|
||||
}
|
||||
|
||||
func failedSync(layout string, force bool, err error, detail string) *SyncResult {
|
||||
return &SyncResult{
|
||||
Action: "failed",
|
||||
Err: err,
|
||||
Detail: detail,
|
||||
Force: force,
|
||||
Layout: layout,
|
||||
}
|
||||
}
|
||||
|
||||
func listOfficialSkills(runner SkillsRunner) ([]string, string, bool) {
|
||||
reasons := []string{}
|
||||
|
||||
@@ -383,8 +441,9 @@ func listOfficialSkills(runner SkillsRunner) ([]string, string, bool) {
|
||||
func listLocalSkills(runner SkillsRunner) ([]string, bool) {
|
||||
jsonResult := runner.ListGlobalSkillsJSON()
|
||||
if jsonResult != nil && jsonResult.Err == nil {
|
||||
if local := ParseGlobalSkillsJSON(jsonResult.Stdout.String()); len(local) > 0 {
|
||||
return local, true
|
||||
infos, valid := parseGlobalSkillInfosJSON(jsonResult.Stdout.String())
|
||||
if valid {
|
||||
return installedSkillNamesFromInfos(infos), true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,6 +470,7 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy
|
||||
Err: fmt.Errorf("full skills install failed: empty result (reason: %s)", reason),
|
||||
Detail: reason,
|
||||
Force: opts.Force,
|
||||
Layout: LayoutSeparate,
|
||||
}
|
||||
}
|
||||
if installResult.Err != nil {
|
||||
@@ -419,11 +479,13 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy
|
||||
Err: fmt.Errorf("full skills install failed: %w (reason: %s)", installResult.Err, reason),
|
||||
Detail: reason + "\n" + resultDetail(installResult),
|
||||
Force: opts.Force,
|
||||
Layout: LayoutSeparate,
|
||||
}
|
||||
}
|
||||
|
||||
state := SkillsState{
|
||||
Version: opts.Version,
|
||||
Layout: LayoutSeparate,
|
||||
OfficialSkills: official,
|
||||
UpdatedSkills: official,
|
||||
AddedOfficialSkills: official,
|
||||
@@ -439,6 +501,7 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy
|
||||
SkippedDeleted: []string{},
|
||||
Detail: reason + "\nstate write failed: " + writeErr.Error(),
|
||||
Force: opts.Force,
|
||||
Layout: LayoutSeparate,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,9 +513,38 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy
|
||||
SkippedDeleted: []string{},
|
||||
Detail: reason,
|
||||
Force: opts.Force,
|
||||
Layout: LayoutSeparate,
|
||||
}
|
||||
}
|
||||
|
||||
func stateFlatSkills(layout string, requested []string) []string {
|
||||
if layout != LayoutHybrid {
|
||||
return []string{}
|
||||
}
|
||||
out := []string{}
|
||||
for _, skill := range uniqueSorted(requested) {
|
||||
if skill != sharedSkillName {
|
||||
out = append(out, skill)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func newlyOfficialSkills(official []string, previous *SkillsState, stateReadable bool) []string {
|
||||
previousOfficial := []string{}
|
||||
if stateReadable && previous != nil {
|
||||
previousOfficial = previous.OfficialSkills
|
||||
}
|
||||
previousSet := toSet(previousOfficial)
|
||||
added := []string{}
|
||||
for _, skill := range official {
|
||||
if !previousSet[skill] {
|
||||
added = append(added, skill)
|
||||
}
|
||||
}
|
||||
return uniqueSorted(added)
|
||||
}
|
||||
|
||||
func resultDetail(result *selfupdate.NpmResult) string {
|
||||
if result == nil {
|
||||
return ""
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -43,6 +44,14 @@ func TestParseOfficialSkillsListAcceptsNonLarkOfficialNames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFlatSkillsTrimsDeduplicatesAndSorts(t *testing.T) {
|
||||
got := ParseFlatSkills(" lark-doc, lark-im,,lark-doc, lark-base ")
|
||||
want := []string{"lark-base", "lark-doc", "lark-im"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ParseFlatSkills() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlobalSkillsList(t *testing.T) {
|
||||
input := `Global Skills
|
||||
|
||||
@@ -216,8 +225,10 @@ type fakeSkillsRunner struct {
|
||||
globalErr error
|
||||
installErr error
|
||||
installAllErr error
|
||||
installSuiteErr error
|
||||
installed [][]string
|
||||
installedAll int
|
||||
installedSuite int
|
||||
listedIndex int
|
||||
listedOfficial int
|
||||
listedGlobalJSON int
|
||||
@@ -319,6 +330,13 @@ func (f *fakeSkillsRunner) InstallAllSkills() *selfupdate.NpmResult {
|
||||
return r
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) InstallSuiteSkill() *selfupdate.NpmResult {
|
||||
f.installedSuite++
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Err = f.installSuiteErr
|
||||
return r
|
||||
}
|
||||
|
||||
func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
@@ -472,6 +490,34 @@ func TestSyncSkills_OfficialDiscoveryEmptyFallsBackToFullInstallWithReasons(t *t
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_HybridOfficialDiscoveryFailureDoesNotFallback(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialErr: fmt.Errorf("list unavailable"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
Layout: LayoutHybrid,
|
||||
Runner: runner,
|
||||
Now: time.Now,
|
||||
})
|
||||
if result.Action != "failed" {
|
||||
t.Fatalf("SyncSkills() action = %q, want failed", result.Action)
|
||||
}
|
||||
if result.Layout != LayoutHybrid {
|
||||
t.Fatalf("SyncSkills() layout = %q, want %q", result.Layout, LayoutHybrid)
|
||||
}
|
||||
if result.Err == nil || !strings.Contains(result.Err.Error(), "failed to discover official skills for hybrid layout") {
|
||||
t.Fatalf("SyncSkills() err = %v, want hybrid discovery failure", result.Err)
|
||||
}
|
||||
if runner.installedAll != 0 {
|
||||
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_ListOfficialFailureFallsBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
@@ -574,7 +620,7 @@ func TestSyncSkills_LocalListsFailureFallsBackToFullInstall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) {
|
||||
func TestSyncSkills_EmptyGlobalJSONInstallsAllOfficialIncrementally(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
@@ -585,14 +631,15 @@ func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) {
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
if result.Action != "synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
|
||||
}
|
||||
if len(runner.installed) != 0 {
|
||||
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
|
||||
if len(runner.installed) != 1 {
|
||||
t.Fatalf("installed = %#v, want one incremental install", runner.installed)
|
||||
}
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
|
||||
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-mail"})
|
||||
if runner.installedAll != 0 {
|
||||
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -697,6 +744,200 @@ func TestSyncSkills_NilRunnerFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_HybridAssemblesSuiteAndMovesCollectedSkills(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
paths := map[string]string{}
|
||||
for _, name := range []string{"lark-calendar", "lark-doc", "lark-shared", "lark-suite"} {
|
||||
paths[name] = filepath.Join(dir, name)
|
||||
writeTestSkill(t, paths[name], name)
|
||||
}
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-doc", "lark-shared"),
|
||||
globalJSONOut: globalSkillsJSONFromPaths(paths),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
Layout: LayoutHybrid,
|
||||
FlatSkills: []string{"lark-calendar"},
|
||||
Runner: runner,
|
||||
Now: time.Now,
|
||||
})
|
||||
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
if runner.installedSuite != 1 {
|
||||
t.Fatalf("installedSuite = %d, want 1", runner.installedSuite)
|
||||
}
|
||||
assertStrings(t, result.Flat, []string{"lark-calendar"})
|
||||
assertStrings(t, result.Collected, []string{"lark-shared", "lark-doc"})
|
||||
if _, err := os.Stat(filepath.Join(paths["lark-suite"], "references", "subskills", "lark-doc", "SKILL.md")); err != nil {
|
||||
t.Fatalf("suite lark-doc missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(paths["lark-suite"], "references", "subskills", "lark-shared", "SKILL.md")); err != nil {
|
||||
t.Fatalf("suite lark-shared missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(paths["lark-calendar"], "SKILL.md")); err != nil {
|
||||
t.Fatalf("flat lark-calendar missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(paths["lark-doc"], "SKILL.md")); !os.IsNotExist(err) {
|
||||
t.Fatalf("collected lark-doc still exists at top level or unexpected err: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(paths["lark-shared"], "SKILL.md")); err != nil {
|
||||
t.Fatalf("lark-shared should stay top-level when flat set is non-empty: %v", err)
|
||||
}
|
||||
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Layout != LayoutHybrid {
|
||||
t.Fatalf("state.Layout = %q, want %q", state.Layout, LayoutHybrid)
|
||||
}
|
||||
assertStrings(t, state.FlatSkills, []string{"lark-calendar"})
|
||||
}
|
||||
|
||||
func TestSyncSkills_HybridCollectsNewOfficialSkillOnNextUpdate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
paths := map[string]string{}
|
||||
for _, name := range []string{"lark-calendar", "lark-doc", "lark-shared", "lark-suite"} {
|
||||
paths[name] = filepath.Join(dir, name)
|
||||
writeTestSkill(t, paths[name], name)
|
||||
}
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-doc", "lark-shared"),
|
||||
globalJSONOut: globalSkillsJSONFromPaths(paths),
|
||||
}
|
||||
first := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
Layout: LayoutHybrid,
|
||||
FlatSkills: []string{"lark-calendar"},
|
||||
Runner: runner,
|
||||
Now: time.Now,
|
||||
})
|
||||
if first.Err != nil {
|
||||
t.Fatalf("first SyncSkills() err = %v, want nil", first.Err)
|
||||
}
|
||||
|
||||
paths["lark-new"] = filepath.Join(dir, "lark-new")
|
||||
for _, name := range []string{"lark-doc", "lark-new", "lark-shared", "lark-suite"} {
|
||||
writeTestSkill(t, paths[name], name)
|
||||
}
|
||||
runner.officialIndexOut = officialSkillsIndexOutput("lark-calendar", "lark-doc", "lark-new", "lark-shared")
|
||||
runner.globalJSONOut = globalSkillsJSONFromPaths(paths)
|
||||
|
||||
second := SyncSkills(SyncOptions{
|
||||
Version: "1.0.34",
|
||||
Layout: LayoutHybrid,
|
||||
FlatSkills: []string{"lark-calendar"},
|
||||
Runner: runner,
|
||||
Now: time.Now,
|
||||
})
|
||||
if second.Err != nil {
|
||||
t.Fatalf("second SyncSkills() err = %v, want nil", second.Err)
|
||||
}
|
||||
assertStrings(t, second.Added, []string{"lark-new"})
|
||||
assertStrings(t, second.Flat, []string{"lark-calendar"})
|
||||
assertStrings(t, second.Collected, []string{"lark-shared", "lark-doc", "lark-new"})
|
||||
|
||||
if _, err := os.Stat(filepath.Join(paths["lark-suite"], "references", "subskills", "lark-new", "SKILL.md")); err != nil {
|
||||
t.Fatalf("suite lark-new missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(paths["lark-new"], "SKILL.md")); !os.IsNotExist(err) {
|
||||
t.Fatalf("new official skill should be collected, top-level path err: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(paths["lark-suite"], "SKILL.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read suite SKILL.md: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "- lark-new: lark-new description") {
|
||||
t.Fatalf("suite SKILL.md missing lark-new route:\n%s", data)
|
||||
}
|
||||
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
assertStrings(t, state.OfficialSkills, []string{"lark-calendar", "lark-doc", "lark-new", "lark-shared"})
|
||||
assertStrings(t, state.AddedOfficialSkills, []string{"lark-new"})
|
||||
assertStrings(t, state.FlatSkills, []string{"lark-calendar"})
|
||||
}
|
||||
|
||||
func TestSyncSkills_HybridWithNoFlatSkillsDoesNotKeepSharedTopLevel(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
paths := map[string]string{}
|
||||
for _, name := range []string{"lark-shared", "lark-suite"} {
|
||||
paths[name] = filepath.Join(dir, name)
|
||||
writeTestSkill(t, paths[name], name)
|
||||
}
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-shared"),
|
||||
globalJSONOut: globalSkillsJSONFromPaths(paths),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
Layout: LayoutHybrid,
|
||||
Runner: runner,
|
||||
Now: time.Now,
|
||||
})
|
||||
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(paths["lark-shared"], "SKILL.md")); !os.IsNotExist(err) {
|
||||
t.Fatalf("lark-shared should not stay top-level when flat set is empty; err: %v", err)
|
||||
}
|
||||
if result.Flat == nil || len(result.Flat) != 0 {
|
||||
t.Fatalf("result.Flat = %#v, want empty slice", result.Flat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_HybridFiltersNonFlatOfficialSkills(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
paths := map[string]string{}
|
||||
for _, name := range []string{"lark-calendar", "lark-shared", "lark-suite"} {
|
||||
paths[name] = filepath.Join(dir, name)
|
||||
writeTestSkill(t, paths[name], name)
|
||||
}
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-shared"),
|
||||
globalJSONOut: globalSkillsJSONFromPaths(paths),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
Layout: LayoutHybrid,
|
||||
FlatSkills: []string{"lark-calendar", "lark-missing", "lark-shared"},
|
||||
Runner: runner,
|
||||
Now: time.Now,
|
||||
})
|
||||
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
assertStrings(t, result.Flat, []string{"lark-calendar"})
|
||||
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
assertStrings(t, state.FlatSkills, []string{"lark-calendar"})
|
||||
}
|
||||
|
||||
func TestSyncSkills_ParseEmptyWithNonEmptyStdoutFallsBack(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
@@ -733,6 +974,81 @@ func TestSyncSkills_ParseEmptyWithNonEmptyStdoutAndFullInstallFails(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSuiteTemplateTextRewritesLegacyFlatSkillWording(t *testing.T) {
|
||||
input := "`lark-shared` 是共享基础能力,不作为 `--collected-skills` 的可选项。为了保证 suite 内子能力可用,hybrid 布局会同时保留顶层 `lark-shared`,并在 `lark-suite/references/subskills/lark-shared/SKILL.md` 中维护一份副本。"
|
||||
got := normalizeSuiteTemplateText(input)
|
||||
if strings.Contains(got, "--collected-skills") {
|
||||
t.Fatalf("normalized text still contains legacy flag: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "--flat-skills") {
|
||||
t.Fatalf("normalized text missing --flat-skills: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "只有 hybrid 布局存在平铺 skill 时,顶层才会额外保留一份") {
|
||||
t.Fatalf("normalized text missing current lark-shared rule: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillDescriptionSupportsFoldedYAMLScalar(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "SKILL.md")
|
||||
content := `---
|
||||
name: lark-whiteboard
|
||||
description: >
|
||||
飞书画板:查询和编辑飞书云文档中的画板。
|
||||
当用户需要查看画板内容、导出画板图片、编辑画板时使用此 skill。
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
---
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := skillDescription(path)
|
||||
want := "飞书画板:查询和编辑飞书云文档中的画板。 当用户需要查看画板内容、导出画板图片、编辑画板时使用此 skill。"
|
||||
if got != want {
|
||||
t.Fatalf("skillDescription() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleSuiteLayoutSeparateIsNoop(t *testing.T) {
|
||||
if err := assembleSuiteLayout(LayoutSeparate, []string{"lark-doc"}, false, nil); err != nil {
|
||||
t.Fatalf("assembleSuiteLayout(separate) err = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleSuiteLayoutMissingSuiteReturnsError(t *testing.T) {
|
||||
err := assembleSuiteLayout(LayoutHybrid, []string{"lark-doc"}, false, []GlobalSkillInfo{
|
||||
{Name: "lark-doc", Path: filepath.Join(t.TempDir(), "lark-doc")},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "lark-suite") {
|
||||
t.Fatalf("assembleSuiteLayout() err = %v, want missing lark-suite error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyDirCopiesNestedFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
src := filepath.Join(root, "src")
|
||||
dst := filepath.Join(root, "dst")
|
||||
if err := os.MkdirAll(filepath.Join(src, "nested"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(src, "nested", "file.txt"), []byte("hello"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := copyDir(src, dst); err != nil {
|
||||
t.Fatalf("copyDir() err = %v, want nil", err)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(dst, "nested", "file.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "hello" {
|
||||
t.Fatalf("copied file = %q, want hello", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func assertStrings(t *testing.T, got, want []string) {
|
||||
t.Helper()
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
@@ -740,6 +1056,38 @@ func assertStrings(t *testing.T, got, want []string) {
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestSkill(t *testing.T, dir, name string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := fmt.Sprintf("---\nname: %s\ndescription: %s description\n---\n", name, name)
|
||||
if name == suiteSkillName {
|
||||
content = "---\nname: lark-suite\ndescription: Lark suite\n---\n<!-- LARK_SUITE_ROUTES -->\n"
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func globalSkillsJSONFromPaths(paths map[string]string) string {
|
||||
names := make([]string, 0, len(paths))
|
||||
for name := range paths {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
var b strings.Builder
|
||||
b.WriteString("[")
|
||||
for i, name := range names {
|
||||
if i > 0 {
|
||||
b.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&b, `{"name":%q,"path":%q,"scope":"global","agents":["Codex"]}`, name, paths[name])
|
||||
}
|
||||
b.WriteString("]")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func TestSyncSkills_FallbackWithUnknownOfficialWritesMinimalState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
33
isolated-skills/lark-suite/SKILL.md
Normal file
33
isolated-skills/lark-suite/SKILL.md
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: lark-suite
|
||||
version: 0.1.0
|
||||
description: 飞书/Lark 聚合能力入口:当用户需求涉及本文件列出的任一飞书能力时使用;仅负责选择并加载已安装到本 suite 的 lark-* 子能力,不替代具体子能力的操作细节。
|
||||
metadata:
|
||||
requires:
|
||||
bins:
|
||||
- lark-cli
|
||||
---
|
||||
|
||||
# Lark Suite
|
||||
|
||||
你是飞书/Lark 能力的聚合路由层。你的职责是先判断用户要使用哪个 `lark-*` 子能力,再读取并遵循对应子能力的说明。
|
||||
|
||||
`lark-suite` 不直接承载具体 API 操作步骤。除非对应子能力已被读取,否则不要仅根据本文件拼命令、猜参数或执行复杂操作。
|
||||
|
||||
## 使用流程
|
||||
|
||||
1. 根据用户意图从下方路由表选择一个或多个子能力;即使用户尚未提供链接、ID 或具体工作表,也先选择能力,再由子能力询问缺失信息。
|
||||
2. 仅使用本文件列出的路由与对应子能力入口,不要遍历或探测其他技能目录。
|
||||
3. 如果目标能力未列出,返回无法路由的明确提示。
|
||||
4. 仅读取当前已选子能力明确要求的前置文件。
|
||||
5. 按目标子能力的说明执行;认证、租户、身份、权限和通用排障优先遵循 `lark-shared`。
|
||||
|
||||
`lark-shared` 是共享基础能力,不作为 `--flat-skills` 的可选项。为了保证 suite 内子能力可用,它始终会进入 `lark-suite/references/subskills/lark-shared/SKILL.md`;只有 hybrid 布局存在平铺 skill 时,顶层才会额外保留一份 `lark-shared`。
|
||||
|
||||
多步任务可以组合多个子能力,但每一步都应由具体子能力驱动。例如“查联系人并发消息”先用 `lark-contact` 解析身份,再用 `lark-im` 发消息。
|
||||
|
||||
## 能力路由
|
||||
|
||||
根据用户意图从以下条目选择对应子能力;如果一个任务涉及多个能力,按实际操作顺序逐步读取并使用对应子能力。
|
||||
|
||||
<!-- LARK_SUITE_ROUTES -->
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.66",
|
||||
"version": "1.0.65",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -215,73 +215,6 @@ 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
|
||||
@@ -304,23 +237,13 @@ 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() && 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"
|
||||
if ! grep -Fq "if: \${{ !cancelled() }}" <<<"$section"; then
|
||||
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
// 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,
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
#!/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,
|
||||
};
|
||||
@@ -1,94 +0,0 @@
|
||||
// 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,7 +4,6 @@
|
||||
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { labelDomainsForPath } = require("../domain-map");
|
||||
|
||||
// ============================================================================
|
||||
// Constants & Configuration
|
||||
@@ -36,6 +35,33 @@ 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 = {
|
||||
@@ -259,7 +285,13 @@ function skillDomainForPath(filePath) {
|
||||
|
||||
// Get business domain label based on CODEOWNERS path mapping
|
||||
function getBusinessDomain(filePath) {
|
||||
return labelDomainsForPath(filePath)[0] || "";
|
||||
const normalized = normalizePath(filePath);
|
||||
for (const [prefix, domain] of Object.entries(PATH_TO_DOMAIN_MAP)) {
|
||||
if (normalized.startsWith(prefix)) {
|
||||
return domain;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function detectNewShortcutDomain(files) {
|
||||
|
||||
@@ -8,17 +8,7 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
script="$repo_root/scripts/resolve-changed-from.sh"
|
||||
|
||||
tmp="${TMPDIR:-/tmp}/resolve-changed-from-test-$$"
|
||||
|
||||
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
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
mkdir -p "$tmp"
|
||||
|
||||
git_init() {
|
||||
|
||||
@@ -306,9 +306,6 @@ var CalendarCreate = common.Shortcut{
|
||||
"start": startStr,
|
||||
"end": endStr,
|
||||
}
|
||||
if recurrence, _ := event["recurrence"].(string); recurrence != "" {
|
||||
resultData["recurrence"] = recurrence
|
||||
}
|
||||
|
||||
runtime.OutFormat(resultData, nil, func(w io.Writer) {
|
||||
var rows []map[string]interface{}
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// calendar +get — get a single calendar event detail by calendar_id and event_id
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// calendarEventTime mirrors start_time / end_time in the API response.
|
||||
type calendarEventTime struct {
|
||||
Date string `json:"date,omitempty"`
|
||||
Timestamp string `json:"timestamp,omitempty"`
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
}
|
||||
|
||||
// calendarEventVChat mirrors the vchat block in the API response.
|
||||
type calendarEventVChat struct {
|
||||
VCType string `json:"vc_type,omitempty"`
|
||||
IconType string `json:"icon_type,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
MeetingURL string `json:"meeting_url,omitempty"`
|
||||
}
|
||||
|
||||
// calendarEventLocation mirrors the location block in the API response.
|
||||
type calendarEventLocation struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Latitude float64 `json:"latitude,omitempty"`
|
||||
Longitude float64 `json:"longitude,omitempty"`
|
||||
}
|
||||
|
||||
// calendarEventReminder mirrors a reminder entry.
|
||||
type calendarEventReminder struct {
|
||||
Minutes int `json:"minutes"`
|
||||
}
|
||||
|
||||
// calendarEventOrganizer mirrors event_organizer.
|
||||
type calendarEventOrganizer struct {
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
}
|
||||
|
||||
// calendarEventAttachment mirrors a single attachment entry.
|
||||
type calendarEventAttachment struct {
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
FileSize string `json:"file_size,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// calendarEventCheckInTime mirrors check_in_start_time / check_in_end_time.
|
||||
type calendarEventCheckInTime struct {
|
||||
TimeType string `json:"time_type,omitempty"`
|
||||
Duration int `json:"duration"`
|
||||
}
|
||||
|
||||
// calendarEventCheckIn mirrors event_check_in.
|
||||
type calendarEventCheckIn struct {
|
||||
EnableCheckIn bool `json:"enable_check_in"`
|
||||
CheckInStartTime *calendarEventCheckInTime `json:"check_in_start_time,omitempty"`
|
||||
CheckInEndTime *calendarEventCheckInTime `json:"check_in_end_time,omitempty"`
|
||||
NeedNotifyAttendees bool `json:"need_notify_attendees"`
|
||||
}
|
||||
|
||||
// calendarEvent mirrors the event object inside the API response.
|
||||
type calendarEvent struct {
|
||||
EventID string `json:"event_id,omitempty"`
|
||||
OrganizerCalendarID string `json:"organizer_calendar_id,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
StartTime *calendarEventTime `json:"start_time,omitempty"`
|
||||
EndTime *calendarEventTime `json:"end_time,omitempty"`
|
||||
VChat *calendarEventVChat `json:"vchat,omitempty"`
|
||||
Visibility string `json:"visibility,omitempty"`
|
||||
AttendeeAbility string `json:"attendee_ability,omitempty"`
|
||||
FreeBusyStatus string `json:"free_busy_status,omitempty"`
|
||||
SelfRsvpStatus string `json:"self_rsvp_status,omitempty"`
|
||||
Location *calendarEventLocation `json:"location,omitempty"`
|
||||
Color int `json:"color,omitempty"`
|
||||
Reminders []calendarEventReminder `json:"reminders,omitempty"`
|
||||
Recurrence string `json:"recurrence,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
IsException bool `json:"is_exception,omitempty"`
|
||||
RecurringEventID string `json:"recurring_event_id,omitempty"`
|
||||
CreateTime string `json:"create_time,omitempty"`
|
||||
EventOrganizer *calendarEventOrganizer `json:"event_organizer,omitempty"`
|
||||
AppLink string `json:"app_link,omitempty"`
|
||||
Attachments []calendarEventAttachment `json:"attachments,omitempty"`
|
||||
EventCheckIn *calendarEventCheckIn `json:"event_check_in,omitempty"`
|
||||
}
|
||||
|
||||
// parseCalendarEvent decodes the API response data into a typed calendarEvent.
|
||||
func parseCalendarEvent(data map[string]any) (*calendarEvent, error) {
|
||||
rawEvent, ok := data["event"]
|
||||
if !ok || rawEvent == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response missing 'event' field")
|
||||
}
|
||||
raw, err := json.Marshal(rawEvent)
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response: marshal failed: %s", err).WithCause(err)
|
||||
}
|
||||
var event calendarEvent
|
||||
if err := json.Unmarshal(raw, &event); err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response: unmarshal failed: %s", err).WithCause(err)
|
||||
}
|
||||
return &event, nil
|
||||
}
|
||||
|
||||
// buildCalendarEventOutput converts the typed event into the output map and
|
||||
// applies the four transformation rules:
|
||||
// 1. create_time -> RFC3339
|
||||
// 2. start_time / end_time timestamp -> datetime (RFC3339), drop timestamp
|
||||
// 3. flatten event into the top-level result
|
||||
// 4. when status != "cancelled", drop status (and adjust all-day end date)
|
||||
func buildCalendarEventOutput(event *calendarEvent) (map[string]interface{}, error) {
|
||||
raw, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event marshal failed: %s", err).WithCause(err)
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event unmarshal failed: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
if ctStr, ok := out["create_time"].(string); ok && ctStr != "" {
|
||||
if ts, err := strconv.ParseInt(ctStr, 10, 64); err == nil {
|
||||
out["create_time"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
|
||||
if startMap, ok := out["start_time"].(map[string]interface{}); ok {
|
||||
if tsStr, ok := startMap["timestamp"].(string); ok && tsStr != "" {
|
||||
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
|
||||
startMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
|
||||
delete(startMap, "timestamp")
|
||||
}
|
||||
}
|
||||
}
|
||||
if endMap, ok := out["end_time"].(map[string]interface{}); ok {
|
||||
if tsStr, ok := endMap["timestamp"].(string); ok && tsStr != "" {
|
||||
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
|
||||
endMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
|
||||
delete(endMap, "timestamp")
|
||||
}
|
||||
}
|
||||
// All-day event: end date is exclusive in the API; rewind by 1s and reformat.
|
||||
if dt, _ := endMap["datetime"].(string); dt == "" {
|
||||
if dateStr, ok := endMap["date"].(string); ok && dateStr != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02", dateStr, time.UTC); err == nil {
|
||||
endMap["date"] = t.Add(-1 * time.Second).Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if status, _ := out["status"].(string); status != "cancelled" {
|
||||
delete(out, "status")
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CalendarGet gets a single calendar event detail.
|
||||
var CalendarGet = common.Shortcut{
|
||||
Service: "calendar",
|
||||
Command: "+get",
|
||||
Description: "Get a single calendar event detail by calendar-id and event-id",
|
||||
Risk: "read",
|
||||
Scopes: []string{"calendar:calendar.event:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
|
||||
{Name: "event-id", Desc: "event ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, flag := range []string{"calendar-id", "event-id"} {
|
||||
if val := strings.TrimSpace(runtime.Str(flag)); val != "" {
|
||||
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||
if eventId == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "event-id cannot be empty").WithParam("--event-id")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
calendarId := strings.TrimSpace(runtime.Str("calendar-id"))
|
||||
d := common.NewDryRunAPI()
|
||||
switch calendarId {
|
||||
case "":
|
||||
d.Desc("(calendar-id omitted) Will use primary calendar")
|
||||
calendarId = "<primary>"
|
||||
case "primary":
|
||||
calendarId = "<primary>"
|
||||
}
|
||||
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||
return d.
|
||||
GET("/open-apis/calendar/v4/calendars/:calendar_id/events/:event_id").
|
||||
Set("calendar_id", calendarId).
|
||||
Set("event_id", eventId)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
calendarId := strings.TrimSpace(runtime.Str("calendar-id"))
|
||||
if calendarId == "" {
|
||||
calendarId = PrimaryCalendarIDStr
|
||||
}
|
||||
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||
|
||||
data, err := runtime.CallAPITyped("GET",
|
||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s",
|
||||
validate.EncodePathSegment(calendarId),
|
||||
validate.EncodePathSegment(eventId)),
|
||||
nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
event, err := parseCalendarEvent(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := buildCalendarEventOutput(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
summary, _ := out["summary"].(string)
|
||||
if summary == "" {
|
||||
summary = "(untitled)"
|
||||
}
|
||||
startMap, _ := out["start_time"].(map[string]interface{})
|
||||
endMap, _ := out["end_time"].(map[string]interface{})
|
||||
startStr, _ := startMap["datetime"].(string)
|
||||
if startStr == "" {
|
||||
startStr, _ = startMap["date"].(string)
|
||||
}
|
||||
endStr, _ := endMap["datetime"].(string)
|
||||
if endStr == "" {
|
||||
endStr, _ = endMap["date"].(string)
|
||||
}
|
||||
eventIdOut, _ := out["event_id"].(string)
|
||||
freeBusyStatus, _ := out["free_busy_status"].(string)
|
||||
selfRsvpStatus, _ := out["self_rsvp_status"].(string)
|
||||
row := map[string]interface{}{
|
||||
"event_id": eventIdOut,
|
||||
"summary": summary,
|
||||
"start": startStr,
|
||||
"end": endStr,
|
||||
"free_busy_status": freeBusyStatus,
|
||||
"self_rsvp_status": selfRsvpStatus,
|
||||
}
|
||||
output.PrintTable(w, []map[string]interface{}{row})
|
||||
fmt.Fprintln(w)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -2304,17 +2304,17 @@ func TestResolveStartEnd_ExplicitValues(t *testing.T) {
|
||||
// Shortcuts() registration test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestShortcuts_Returns10(t *testing.T) {
|
||||
func TestShortcuts_Returns9(t *testing.T) {
|
||||
shortcuts := Shortcuts()
|
||||
if len(shortcuts) != 10 {
|
||||
t.Fatalf("expected 10 shortcuts, got %d", len(shortcuts))
|
||||
if len(shortcuts) != 9 {
|
||||
t.Fatalf("expected 9 shortcuts, got %d", len(shortcuts))
|
||||
}
|
||||
|
||||
names := map[string]bool{}
|
||||
for _, s := range shortcuts {
|
||||
names[s.Command] = true
|
||||
}
|
||||
for _, want := range []string{"+agenda", "+create", "+update", "+freebusy", "+room-find", "+rsvp", "+suggestion", "+get"} {
|
||||
for _, want := range []string{"+agenda", "+create", "+update", "+freebusy", "+room-find", "+rsvp", "+suggestion"} {
|
||||
if !names[want] {
|
||||
t.Errorf("missing shortcut %s", want)
|
||||
}
|
||||
@@ -3178,193 +3178,3 @@ func TestSuggestion_RejectsDangerousTimezone_Typed(t *testing.T) {
|
||||
t.Errorf("param=%q, want --timezone", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CalendarGet tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGet_Success_FlattensAndConvertsTimes(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_001",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_001",
|
||||
"summary": "Daily Sync",
|
||||
"create_time": "1602504000",
|
||||
"start_time": map[string]interface{}{
|
||||
"timestamp": "1742515200",
|
||||
"timezone": "Asia/Shanghai",
|
||||
},
|
||||
"end_time": map[string]interface{}{
|
||||
"timestamp": "1742518800",
|
||||
"timezone": "Asia/Shanghai",
|
||||
},
|
||||
"status": "confirmed",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarGet, []string{
|
||||
"+get",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--event-id", "evt_001",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
// Expect flattened — fields appear directly under "data", not under "data.event"
|
||||
if strings.Contains(out, "\"event\": {") {
|
||||
t.Errorf("payload should be flattened (no event wrapper), got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "\"event_id\": \"evt_001\"") {
|
||||
t.Errorf("expected event_id in output, got: %s", out)
|
||||
}
|
||||
// status=confirmed should be dropped
|
||||
if strings.Contains(out, "\"status\": \"confirmed\"") {
|
||||
t.Errorf("status should be dropped when not cancelled, got: %s", out)
|
||||
}
|
||||
// timestamp must be replaced with datetime
|
||||
if strings.Contains(out, "\"timestamp\":") {
|
||||
t.Errorf("timestamp should be replaced with datetime, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "\"datetime\":") {
|
||||
t.Errorf("expected datetime in output, got: %s", out)
|
||||
}
|
||||
// create_time must be RFC3339 (contain 'T' and timezone)
|
||||
if !strings.Contains(out, "\"create_time\": \"2020-10-12T") {
|
||||
t.Errorf("expected RFC3339 create_time, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_CancelledStatus_PreservesStatus(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_002",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_002",
|
||||
"summary": "Cancelled Meeting",
|
||||
"create_time": "1602504000",
|
||||
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
||||
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
||||
"status": "cancelled",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarGet, []string{
|
||||
"+get",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--event-id", "evt_002",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "\"status\": \"cancelled\"") {
|
||||
t.Errorf("status should be preserved when cancelled, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_AllDayEvent_AdjustsEndDate(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
// All-day event: start 2025-03-21, end 2025-03-22 (exclusive in API).
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_003",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_003",
|
||||
"summary": "All-day",
|
||||
"start_time": map[string]interface{}{"date": "2025-03-21"},
|
||||
"end_time": map[string]interface{}{"date": "2025-03-22"},
|
||||
"status": "confirmed",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarGet, []string{
|
||||
"+get",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--event-id", "evt_003",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
// end date 2025-03-22 should rewind by 1s -> 2025-03-21
|
||||
if !strings.Contains(out, "\"date\": \"2025-03-21\"") {
|
||||
t.Errorf("expected end date adjusted to 2025-03-21, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_EmptyEventID_Typed(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, CalendarGet, []string{
|
||||
"+get",
|
||||
"--event-id", " ",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("want error for empty event-id")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("want *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if ve.Param != "--event-id" {
|
||||
t.Errorf("param=%q, want --event-id", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_MissingEventField_TypedInternal(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_404",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarGet, []string{
|
||||
"+get",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--event-id", "evt_404",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("want error when event field is missing")
|
||||
}
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("want *errs.InternalError, got %T", err)
|
||||
}
|
||||
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("subtype=%q, want invalid_response", ie.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,5 @@ func Shortcuts() []common.Shortcut {
|
||||
CalendarSuggestion,
|
||||
CalendarMeeting,
|
||||
CalendarSearchEvent,
|
||||
CalendarGet,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +184,6 @@ var DrivePull = common.Shortcut{
|
||||
|
||||
var downloaded, skipped, failed, deletedLocal int
|
||||
downloadFailed := 0
|
||||
aborted := false
|
||||
items := make([]drivePullItem, 0)
|
||||
|
||||
// Deterministic iteration order for output stability.
|
||||
@@ -195,7 +194,7 @@ var DrivePull = common.Shortcut{
|
||||
sort.Strings(downloadablePaths)
|
||||
|
||||
for _, rel := range downloadablePaths {
|
||||
if aborted {
|
||||
if drivePullHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
targetFile := remoteFiles[rel]
|
||||
@@ -233,7 +232,6 @@ 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
|
||||
}
|
||||
@@ -300,7 +298,7 @@ var DrivePull = common.Shortcut{
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"deleted_local": deletedLocal,
|
||||
"aborted": aborted,
|
||||
"aborted": drivePullHasTerminalFailure(items),
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
@@ -349,6 +347,15 @@ 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,7 +35,6 @@ 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"`
|
||||
@@ -49,7 +48,6 @@ 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
|
||||
@@ -242,7 +240,6 @@ 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
|
||||
@@ -269,7 +266,6 @@ 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
|
||||
}
|
||||
@@ -288,7 +284,7 @@ var DrivePush = common.Shortcut{
|
||||
|
||||
for _, rel := range localPaths {
|
||||
localFile := localFiles[rel]
|
||||
if uploadFailed && aborted {
|
||||
if uploadFailed && drivePushHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -305,7 +301,6 @@ 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
|
||||
}
|
||||
@@ -337,7 +332,6 @@ 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
|
||||
}
|
||||
@@ -356,7 +350,6 @@ 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
|
||||
}
|
||||
@@ -369,7 +362,6 @@ 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
|
||||
}
|
||||
@@ -415,15 +407,10 @@ 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
|
||||
@@ -442,7 +429,7 @@ var DrivePush = common.Shortcut{
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"deleted_remote": deletedRemote,
|
||||
"aborted": aborted,
|
||||
"aborted": drivePushHasTerminalFailure(items),
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
@@ -580,7 +567,6 @@ 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,
|
||||
@@ -627,10 +613,6 @@ 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:
|
||||
@@ -644,9 +626,22 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
|
||||
return decision
|
||||
}
|
||||
|
||||
func drivePushIsAlreadyDeleted(err error) bool {
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
return ok && problem.Code == 1061007
|
||||
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 drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]driveRemoteEntry, map[string]driveRemoteEntry, map[string][]driveRemoteEntry, error) {
|
||||
|
||||
@@ -732,65 +732,6 @@ 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())
|
||||
|
||||
@@ -1196,78 +1137,6 @@ 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,7 +268,6 @@ 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.
|
||||
@@ -287,21 +286,16 @@ 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 aborted {
|
||||
if driveSyncHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
if _, alreadyRemote := folderCache[relDir]; alreadyRemote {
|
||||
continue
|
||||
}
|
||||
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
|
||||
item, terminal := driveSyncFailedItem(relDir, "", "failed", "push", "create_folder", ensureErr)
|
||||
item, _ := 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"})
|
||||
@@ -310,7 +304,7 @@ var DriveSync = common.Shortcut{
|
||||
|
||||
// 2a. Pull new_remote files.
|
||||
for _, entry := range newRemote {
|
||||
if aborted {
|
||||
if driveSyncHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
targetFile, ok := pullRemoteFiles[entry.RelPath]
|
||||
@@ -324,7 +318,6 @@ 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
|
||||
}
|
||||
@@ -336,7 +329,7 @@ var DriveSync = common.Shortcut{
|
||||
|
||||
// 2b. Push new_local files.
|
||||
for _, entry := range newLocal {
|
||||
if aborted {
|
||||
if driveSyncHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
localFile, ok := pushLocalFiles[entry.RelPath]
|
||||
@@ -348,14 +341,9 @@ var DriveSync = common.Shortcut{
|
||||
parentRel := drivePushParentRel(entry.RelPath)
|
||||
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
|
||||
if ensureErr != nil {
|
||||
item, terminal := driveSyncFailedItem(entry.RelPath, "", "failed", "push", "create_folder", ensureErr)
|
||||
item, _ := 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)
|
||||
@@ -364,7 +352,6 @@ 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
|
||||
}
|
||||
@@ -376,7 +363,7 @@ var DriveSync = common.Shortcut{
|
||||
|
||||
// 2c. Resolve modified files by --on-conflict strategy.
|
||||
for _, entry := range modified {
|
||||
if aborted {
|
||||
if driveSyncHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
remoteFile := remoteFiles[entry.RelPath]
|
||||
@@ -410,7 +397,6 @@ 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
|
||||
}
|
||||
@@ -429,14 +415,9 @@ var DriveSync = common.Shortcut{
|
||||
}
|
||||
parentToken, parentErr := drivePushEnsureFolder(ctx, runtime, folderToken, drivePushParentRel(entry.RelPath), folderCache)
|
||||
if parentErr != nil {
|
||||
item, terminal := driveSyncFailedItem(entry.RelPath, existingToken, "failed", "push", "create_folder", parentErr)
|
||||
item, _ := 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)
|
||||
@@ -454,7 +435,6 @@ 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
|
||||
}
|
||||
@@ -523,7 +503,6 @@ 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
|
||||
}
|
||||
@@ -552,7 +531,7 @@ var DriveSync = common.Shortcut{
|
||||
"pushed": pushed,
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"aborted": aborted,
|
||||
"aborted": driveSyncHasTerminalFailure(items),
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
@@ -598,6 +577,15 @@ 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.
|
||||
|
||||
@@ -51,8 +51,9 @@ func hintSendDraft(runtime *common.RuntimeContext, mailboxID, draftID string) {
|
||||
// original message as read after a reply/reply-all/forward operation.
|
||||
func hintMarkAsRead(runtime *common.RuntimeContext, mailboxID, originalMessageID string) {
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"tip: mark original as read? lark-cli mail +message-modify --mailbox '%s' --message-ids '%s' --remove-label-ids UNREAD\n",
|
||||
shellQuoteForHint(mailboxID), shellQuoteForHint(originalMessageID))
|
||||
"tip: mark original as read? lark-cli mail user_mailbox.messages batch_modify_message"+
|
||||
` --params '{"user_mailbox_id":"%s"}' --data '{"message_ids":["%s"],"remove_label_ids":["UNREAD"]}'`+"\n",
|
||||
sanitizeForTerminal(mailboxID), sanitizeForTerminal(originalMessageID))
|
||||
}
|
||||
|
||||
// hintReadReceiptRequest prints a stderr tip when a message that the caller
|
||||
|
||||
@@ -465,19 +465,14 @@ func TestPrintWatchOutputSchema(t *testing.T) {
|
||||
// TestHintMarkAsRead verifies hint mark as read.
|
||||
func TestHintMarkAsRead(t *testing.T) {
|
||||
rt, _, stderr := newOutputRuntime(t)
|
||||
hintMarkAsRead(rt, "mail box;$(whoami)", "msg-\x1b[31m123 'quoted'\nnext")
|
||||
// Inject ANSI escape + message ID to verify sanitization
|
||||
hintMarkAsRead(rt, "me", "msg-\x1b[31m123")
|
||||
out := stderr.String()
|
||||
if strings.Contains(out, "\x1b[") {
|
||||
t.Errorf("hintMarkAsRead should sanitize ANSI escapes, got: %q", out)
|
||||
}
|
||||
if strings.Contains(out, "\nnext") {
|
||||
t.Errorf("hintMarkAsRead should strip embedded newlines, got: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "--mailbox 'mail box;$(whoami)'") {
|
||||
t.Errorf("hintMarkAsRead should quote mailbox for shell copy/paste, got: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "--message-ids 'msg-123 '\\''quoted'\\''next'") {
|
||||
t.Errorf("hintMarkAsRead should quote message ID for shell copy/paste, got: %q", out)
|
||||
if !strings.Contains(out, "msg-123") {
|
||||
t.Errorf("hintMarkAsRead should contain sanitized message ID, got: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,482 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func messageManageID(suffix string) string {
|
||||
return "msg_abcdefghijklmnop_" + suffix
|
||||
}
|
||||
|
||||
func stubMessageManagePost(reg *httpmock.Registry, endpoint string, body map[string]interface{}) *httpmock.Stub {
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/user_mailboxes/me/messages/" + endpoint,
|
||||
Body: body,
|
||||
}
|
||||
reg.Register(stub)
|
||||
return stub
|
||||
}
|
||||
|
||||
func decodeMessageManageSummary(t *testing.T, data map[string]interface{}) ([]interface{}, []interface{}) {
|
||||
t.Helper()
|
||||
success, ok := data["success_message_ids"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("success_message_ids = %#v, want array", data["success_message_ids"])
|
||||
}
|
||||
failed, ok := data["failed_message_ids"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("failed_message_ids = %#v, want array", data["failed_message_ids"])
|
||||
}
|
||||
return success, failed
|
||||
}
|
||||
|
||||
func requireMessageManageValidationParam(t *testing.T, err error, param string) *errs.ValidationError {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error for %s, got nil", param)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError for %s, got %T", param, err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed Problem for %s, got %T", param, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
|
||||
}
|
||||
if validationErr.Param != param {
|
||||
t.Fatalf("param = %q, want %q", validationErr.Param, param)
|
||||
}
|
||||
return validationErr
|
||||
}
|
||||
|
||||
func requireMessageManageFailedPrecondition(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected failed precondition error, got nil")
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed Problem, got %T", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("problem = %s/%s, want validation/failed_precondition", problem.Category, problem.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageManage_NormalizeMessageIDs(t *testing.T) {
|
||||
id1 := messageManageID("1")
|
||||
id2 := messageManageID("2")
|
||||
got, err := normalizeMessageManageIDs([]string{id1, id2, id1})
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeMessageManageIDs returned error: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0] != id1 || got[1] != id2 {
|
||||
t.Fatalf("ids = %v, want [%s %s]", got, id1, id2)
|
||||
}
|
||||
got, err = normalizeMessageManageIDs([]string{id1 + "," + id2, id1})
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeMessageManageIDs CSV/repeated returned error: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0] != id1 || got[1] != id2 {
|
||||
t.Fatalf("CSV/repeated ids = %v, want [%s %s]", got, id1, id2)
|
||||
}
|
||||
|
||||
cases := [][]string{
|
||||
{""},
|
||||
{" id_with_leading_space_12345"},
|
||||
{"msg_abcdefghijklmnop_1,msg_abcdefghijklmnop_2 "},
|
||||
{"1234567890123456"},
|
||||
{"short"},
|
||||
{"msg_abcdefghijklmnop!"},
|
||||
{"msg_abcdefghijklmnop\t"},
|
||||
{"msg_abcdefghijklmnop_1\nmsg_abcdefghijklmnop_2"},
|
||||
{"msg_abcdefghijklmnop_1", "msg_abcdefghijklmnop_2 "},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
_, err := normalizeMessageManageIDs(tc)
|
||||
requireMessageManageValidationParam(t, err, "--message-ids")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_Metadata(t *testing.T) {
|
||||
if MailMessageModify.Command != "+message-modify" {
|
||||
t.Fatalf("Command = %q", MailMessageModify.Command)
|
||||
}
|
||||
if MailMessageModify.Risk != "write" {
|
||||
t.Errorf("Risk = %q, want write", MailMessageModify.Risk)
|
||||
}
|
||||
if len(MailMessageModify.AuthTypes) != 1 || MailMessageModify.AuthTypes[0] != "user" {
|
||||
t.Errorf("AuthTypes = %v, want [user]", MailMessageModify.AuthTypes)
|
||||
}
|
||||
requiredScopes := map[string]bool{
|
||||
"mail:user_mailbox.message:modify": true,
|
||||
}
|
||||
for _, scope := range MailMessageModify.Scopes {
|
||||
delete(requiredScopes, scope)
|
||||
}
|
||||
if len(requiredScopes) != 0 {
|
||||
t.Errorf("Scopes missing %v", requiredScopes)
|
||||
}
|
||||
if len(MailMessageModify.ConditionalScopes) != 1 || MailMessageModify.ConditionalScopes[0] != "mail:user_mailbox.folder:read" {
|
||||
t.Errorf("ConditionalScopes = %v, want [mail:user_mailbox.folder:read]", MailMessageModify.ConditionalScopes)
|
||||
}
|
||||
flags := map[string]common.Flag{}
|
||||
for _, fl := range MailMessageModify.Flags {
|
||||
flags[fl.Name] = fl
|
||||
}
|
||||
for _, name := range []string{"mailbox", "message-ids", "add-label-ids", "remove-label-ids", "add-folder"} {
|
||||
if _, ok := flags[name]; !ok {
|
||||
t.Fatalf("missing --%s flag", name)
|
||||
}
|
||||
}
|
||||
if flags["message-ids"].Type != "string_array" || !flags["message-ids"].Required {
|
||||
t.Errorf("--message-ids = %#v, want required string_array", flags["message-ids"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTrash_Metadata(t *testing.T) {
|
||||
if MailMessageTrash.Command != "+message-trash" {
|
||||
t.Fatalf("Command = %q", MailMessageTrash.Command)
|
||||
}
|
||||
if MailMessageTrash.Risk != "high-risk-write" {
|
||||
t.Errorf("Risk = %q, want high-risk-write", MailMessageTrash.Risk)
|
||||
}
|
||||
if len(MailMessageTrash.AuthTypes) != 1 || MailMessageTrash.AuthTypes[0] != "user" {
|
||||
t.Errorf("AuthTypes = %v, want [user]", MailMessageTrash.AuthTypes)
|
||||
}
|
||||
if len(MailMessageTrash.Scopes) != 1 || MailMessageTrash.Scopes[0] != "mail:user_mailbox.message:modify" {
|
||||
t.Errorf("Scopes = %v, want [mail:user_mailbox.message:modify]", MailMessageTrash.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_LabelOnlyDoesNotRequireFolderReadScope(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
token := auth.GetStoredToken("test-app", "ou_testuser")
|
||||
if token == nil {
|
||||
t.Fatal("expected test token")
|
||||
}
|
||||
token.Scope = strings.ReplaceAll(token.Scope, " mail:user_mailbox.folder:read", "")
|
||||
if err := auth.SetStoredToken(token); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
id := messageManageID("1")
|
||||
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--remove-label-ids", "UNREAD",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v", err)
|
||||
}
|
||||
removeLabels := body["remove_label_ids"].([]interface{})
|
||||
if len(removeLabels) != 1 || removeLabels[0] != "UNREAD" {
|
||||
t.Fatalf("remove_label_ids = %#v, want [UNREAD]", removeLabels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_ReadReceiptRequestLabelIsSystemLabel(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
id := messageManageID("1")
|
||||
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--remove-label-ids", "read_receipt_request",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v", err)
|
||||
}
|
||||
removeLabels := body["remove_label_ids"].([]interface{})
|
||||
if len(removeLabels) != 1 || removeLabels[0] != "READ_RECEIPT_REQUEST" {
|
||||
t.Fatalf("remove_label_ids = %#v, want [READ_RECEIPT_REQUEST]", removeLabels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_LabelFolderNormalizationAndValidationAPIs(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
id := messageManageID("1")
|
||||
reg.Register(&httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/labels/customA", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"label_id": "customA"}}})
|
||||
reg.Register(&httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/folders/folderA", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"folder_id": "folderA"}}})
|
||||
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--add-label-ids", "unread,customA",
|
||||
"--remove-label-ids", "FLAGGED",
|
||||
"--add-folder", "folderA",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v", err)
|
||||
}
|
||||
if got := body["add_folder"]; got != "folderA" {
|
||||
t.Errorf("add_folder = %v, want folderA", got)
|
||||
}
|
||||
addLabels := body["add_label_ids"].([]interface{})
|
||||
if addLabels[0] != "UNREAD" || addLabels[1] != "customA" {
|
||||
t.Errorf("add_label_ids = %#v, want [UNREAD customA]", addLabels)
|
||||
}
|
||||
removeLabels := body["remove_label_ids"].([]interface{})
|
||||
if removeLabels[0] != "FLAGGED" {
|
||||
t.Errorf("remove_label_ids = %#v, want [FLAGGED]", removeLabels)
|
||||
}
|
||||
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
|
||||
if len(success) != 1 || success[0] != id || len(failed) != 0 {
|
||||
t.Errorf("summary success=%v failed=%v", success, failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_RejectsLabelIntersectionAndTrashFolder(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
id := messageManageID("1")
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--add-label-ids", "unread",
|
||||
"--remove-label-ids", "UNREAD",
|
||||
}, f, stdout)
|
||||
requireMessageManageValidationParam(t, err, "--add-label-ids")
|
||||
if !strings.Contains(err.Error(), "label cannot be both added and removed") {
|
||||
t.Fatalf("error = %v, want label intersection validation", err)
|
||||
}
|
||||
|
||||
err = runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--add-folder", "trash",
|
||||
}, f, stdout)
|
||||
requireMessageManageValidationParam(t, err, "--add-folder")
|
||||
if !strings.Contains(err.Error(), "use +message-trash") {
|
||||
t.Fatalf("error = %v, want TRASH validation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_EmptyOperationDoesNotCallPost(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
id1 := messageManageID("1")
|
||||
id2 := messageManageID("2")
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id1 + "," + id2 + "," + id1,
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
|
||||
if len(success) != 2 || success[0] != id1 || success[1] != id2 || len(failed) != 0 {
|
||||
t.Fatalf("summary success=%v failed=%v", success, failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_BatchesAndAggregatesPartialFailure(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
ids := make([]string, 41)
|
||||
for i := range ids {
|
||||
ids[i] = messageManageID(fmt.Sprintf("%02d", i))
|
||||
}
|
||||
first := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
second := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 1230001, "msg": "bad request"})
|
||||
third := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", strings.Join(ids, ","),
|
||||
"--add-folder", "archive",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
for idx, stub := range []*httpmock.Stub{first, second, third} {
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("batch %d body unmarshal: %v", idx+1, err)
|
||||
}
|
||||
messageIDs := body["message_ids"].([]interface{})
|
||||
want := []int{20, 20, 1}[idx]
|
||||
if len(messageIDs) != want {
|
||||
t.Fatalf("batch %d size = %d, want %d", idx+1, len(messageIDs), want)
|
||||
}
|
||||
if body["add_folder"] != "ARCHIVED" {
|
||||
t.Fatalf("batch %d add_folder = %v, want ARCHIVED", idx+1, body["add_folder"])
|
||||
}
|
||||
}
|
||||
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
|
||||
if len(success) != 21 || len(failed) != 20 {
|
||||
t.Fatalf("success=%d failed=%d, want 21/20", len(success), len(failed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_AllBatchesFailReturnsError(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
id := messageManageID("1")
|
||||
stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 1230001, "msg": "bad request"})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--add-folder", "archive",
|
||||
}, f, stdout)
|
||||
requireMessageManageFailedPrecondition(t, err)
|
||||
}
|
||||
|
||||
func TestMessageModify_DryRunShowsPlanWithoutValidationGET(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
id1 := messageManageID("1")
|
||||
id2 := messageManageID("2")
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id1 + "," + id2,
|
||||
"--add-label-ids", "customA",
|
||||
"--add-folder", "folderA",
|
||||
"--dry-run",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run failed: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
`/user_mailboxes/me/messages/batch_modify`,
|
||||
`validation_api_plan`,
|
||||
`/user_mailboxes/me/labels/customA`,
|
||||
`/user_mailboxes/me/folders/folderA`,
|
||||
`will_validate`,
|
||||
`batch_size`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run output missing %q; got %s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTrash_RequiresYesAndBatches(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
id1 := messageManageID("1")
|
||||
id2 := messageManageID("2")
|
||||
err := runMountedMailShortcut(t, MailMessageTrash, []string{
|
||||
"+message-trash",
|
||||
"--message-ids", id1 + "," + id2,
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected confirmation error, got nil")
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitConfirmationRequired {
|
||||
t.Fatalf("exit code = %d, want %d", code, output.ExitConfirmationRequired)
|
||||
}
|
||||
|
||||
post := stubMessageManagePost(reg, "batch_trash", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
err = runMountedMailShortcut(t, MailMessageTrash, []string{
|
||||
"+message-trash",
|
||||
"--message-ids", id1 + "," + id2,
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err with --yes: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v", err)
|
||||
}
|
||||
if got := len(body["message_ids"].([]interface{})); got != 2 {
|
||||
t.Fatalf("message_ids len = %d, want 2", got)
|
||||
}
|
||||
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
|
||||
if len(success) != 2 || len(failed) != 0 {
|
||||
t.Fatalf("summary success=%v failed=%v", success, failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTrash_AllBatchesFailReturnsError(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
id := messageManageID("1")
|
||||
stubMessageManagePost(reg, "batch_trash", map[string]interface{}{"code": 1230001, "msg": "bad request"})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageTrash, []string{
|
||||
"+message-trash",
|
||||
"--message-ids", id,
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
requireMessageManageFailedPrecondition(t, err)
|
||||
}
|
||||
|
||||
func TestMessageManage_RejectsWhitespaceBeforeAPI(t *testing.T) {
|
||||
id1 := messageManageID("1")
|
||||
id2 := messageManageID("2")
|
||||
cases := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "trash newline in repeated flag",
|
||||
shortcut: MailMessageTrash,
|
||||
args: []string{"+message-trash", "--message-ids", id1 + "\n" + id2, "--yes"},
|
||||
},
|
||||
{
|
||||
name: "trash tab in csv flag",
|
||||
shortcut: MailMessageTrash,
|
||||
args: []string{"+message-trash", "--message-ids", id1 + ",\t" + id2, "--yes"},
|
||||
},
|
||||
{
|
||||
name: "modify space in repeated flag",
|
||||
shortcut: MailMessageModify,
|
||||
args: []string{"+message-modify", "--message-ids", id1, "--message-ids", id2 + " ", "--add-folder", "archive"},
|
||||
},
|
||||
{
|
||||
name: "modify space in csv flag",
|
||||
shortcut: MailMessageModify,
|
||||
args: []string{"+message-modify", "--message-ids", id1 + ", " + id2, "--add-folder", "archive"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, tc.shortcut, tc.args, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d; err=%v", code, output.ExitValidation, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "must not contain whitespace or control characters") {
|
||||
t.Fatalf("error = %v, want whitespace/control validation", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type messageModifyInput struct {
|
||||
MessageIDs []string
|
||||
AddLabelIDs []string
|
||||
RemoveLabelIDs []string
|
||||
AddFolder string
|
||||
CustomLabelIDs []string
|
||||
CustomFolderID string
|
||||
ValidationAPIPlans []validationAPIPlan
|
||||
}
|
||||
|
||||
// MailMessageModify is the `+message-modify` shortcut: apply labels, unread
|
||||
// state labels, or a folder move to existing messages in batches of 20.
|
||||
var MailMessageModify = common.Shortcut{
|
||||
Service: "mail",
|
||||
Command: "+message-modify",
|
||||
Description: "Modify existing mail messages by adding/removing label IDs or moving them to a folder. Batches message IDs in groups of 20 and keeps output compact.",
|
||||
Risk: "write",
|
||||
Scopes: []string{"mail:user_mailbox.message:modify"},
|
||||
ConditionalScopes: []string{
|
||||
"mail:user_mailbox.folder:read",
|
||||
},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "mailbox", Desc: "Mailbox email address that owns the messages (default: me)."},
|
||||
{Name: "message-ids", Type: "string_array", Required: true, Desc: "Message IDs to modify; comma-separated or repeat the flag."},
|
||||
{Name: "add-label-ids", Type: "string_slice", Desc: "Label IDs to add. System labels unread/important/other/flagged are normalized to upper case."},
|
||||
{Name: "remove-label-ids", Type: "string_slice", Desc: "Label IDs to remove. System labels unread/important/other/flagged are normalized to upper case."},
|
||||
{Name: "add-folder", Desc: "Folder ID to move messages to. System folders inbox/sent/spam/archive/archived are normalized; TRASH is rejected, use +message-trash."},
|
||||
},
|
||||
Validate: validateMessageModify,
|
||||
DryRun: dryRunMessageModify,
|
||||
Execute: executeMessageModify,
|
||||
}
|
||||
|
||||
func validateMessageModify(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
_, err := buildMessageModifyInput(rt)
|
||||
return err
|
||||
}
|
||||
|
||||
func dryRunMessageModify(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
|
||||
mailboxID := resolveMailboxID(rt)
|
||||
input, _ := buildMessageModifyInput(rt)
|
||||
api := common.NewDryRunAPI().
|
||||
Desc("Modify messages sequentially in batches of 20; dry-run does not call label/folder validation APIs").
|
||||
Set("batch_size", mailMessageManageBatchSize).
|
||||
Set("batches", chunkMessageManageIDs(input.MessageIDs)).
|
||||
Set("validation_api_plan", input.ValidationAPIPlans)
|
||||
for _, batch := range chunkMessageManageIDs(input.MessageIDs) {
|
||||
api = api.POST(mailboxPath(mailboxID, "messages", "batch_modify")).
|
||||
Body(messageManageBody(batch, input.AddLabelIDs, input.RemoveLabelIDs, input.AddFolder))
|
||||
}
|
||||
return api
|
||||
}
|
||||
|
||||
func executeMessageModify(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
mailboxID := resolveMailboxID(rt)
|
||||
input, err := buildMessageModifyInput(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateCustomMessageManageLabels(rt, mailboxID, input.CustomLabelIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateCustomMessageManageFolder(rt, mailboxID, input.CustomFolderID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(input.AddLabelIDs) == 0 && len(input.RemoveLabelIDs) == 0 && input.AddFolder == "" {
|
||||
emitMessageManageSummary(rt, messageManageSummary{
|
||||
SuccessMessageIDs: input.MessageIDs,
|
||||
FailedMessageIDs: []messageManageFailure{},
|
||||
}, true)
|
||||
return nil
|
||||
}
|
||||
|
||||
summary := messageManageSummary{FailedMessageIDs: []messageManageFailure{}}
|
||||
for _, batch := range chunkMessageManageIDs(input.MessageIDs) {
|
||||
_, err := rt.CallAPITyped("POST", mailboxPath(mailboxID, "messages", "batch_modify"), nil,
|
||||
messageManageBody(batch, input.AddLabelIDs, input.RemoveLabelIDs, input.AddFolder))
|
||||
if err != nil {
|
||||
for _, id := range batch {
|
||||
summary.FailedMessageIDs = append(summary.FailedMessageIDs, messageManageFailure{MessageID: id, Reason: err.Error()})
|
||||
}
|
||||
continue
|
||||
}
|
||||
summary.SuccessMessageIDs = append(summary.SuccessMessageIDs, batch...)
|
||||
}
|
||||
emitMessageManageSummary(rt, summary, false)
|
||||
if len(summary.SuccessMessageIDs) == 0 && len(summary.FailedMessageIDs) > 0 {
|
||||
return mailFailedPreconditionError("all message modify batches failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildMessageModifyInput(rt *common.RuntimeContext) (messageModifyInput, error) {
|
||||
messageIDs, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
|
||||
if err != nil {
|
||||
return messageModifyInput{}, err
|
||||
}
|
||||
addLabels, customAddLabels, err := normalizeMessageManageLabels(rt.StrSlice("add-label-ids"), "--add-label-ids")
|
||||
if err != nil {
|
||||
return messageModifyInput{}, err
|
||||
}
|
||||
removeLabels, customRemoveLabels, err := normalizeMessageManageLabels(rt.StrSlice("remove-label-ids"), "--remove-label-ids")
|
||||
if err != nil {
|
||||
return messageModifyInput{}, err
|
||||
}
|
||||
if err := validateLabelIntersection(addLabels, removeLabels); err != nil {
|
||||
return messageModifyInput{}, err
|
||||
}
|
||||
folder, customFolder, err := normalizeMessageManageFolder(rt.Str("add-folder"))
|
||||
if err != nil {
|
||||
return messageModifyInput{}, err
|
||||
}
|
||||
customLabels := append(customAddLabels, customRemoveLabels...)
|
||||
customFolderID := ""
|
||||
if customFolder {
|
||||
customFolderID = folder
|
||||
}
|
||||
return messageModifyInput{
|
||||
MessageIDs: messageIDs,
|
||||
AddLabelIDs: addLabels,
|
||||
RemoveLabelIDs: removeLabels,
|
||||
AddFolder: folder,
|
||||
CustomLabelIDs: customLabels,
|
||||
CustomFolderID: customFolderID,
|
||||
ValidationAPIPlans: messageManageValidationPlan(resolveMailboxID(rt), customLabels, customFolderID),
|
||||
}, nil
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// MailMessageTrash is the `+message-trash` shortcut: soft-delete existing
|
||||
// messages in batches of 20 via batch_trash. Risk is high-risk-write, so the
|
||||
// runner requires --yes before Execute.
|
||||
var MailMessageTrash = common.Shortcut{
|
||||
Service: "mail",
|
||||
Command: "+message-trash",
|
||||
Description: "Soft-delete existing mail messages. Batches message IDs in groups of 20 and calls batch_trash sequentially. Requires --yes.",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"mail:user_mailbox.message:modify"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "mailbox", Desc: "Mailbox email address that owns the messages (default: me)."},
|
||||
{Name: "message-ids", Type: "string_array", Required: true, Desc: "Message IDs to soft-delete; comma-separated or repeat the flag."},
|
||||
},
|
||||
Validate: validateMessageTrash,
|
||||
DryRun: dryRunMessageTrash,
|
||||
Execute: executeMessageTrash,
|
||||
}
|
||||
|
||||
func validateMessageTrash(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
_, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
|
||||
return err
|
||||
}
|
||||
|
||||
func dryRunMessageTrash(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
|
||||
mailboxID := resolveMailboxID(rt)
|
||||
messageIDs, _ := normalizeMessageManageIDs(rt.StrArray("message-ids"))
|
||||
api := common.NewDryRunAPI().
|
||||
Desc("Soft-delete messages sequentially in batches of 20").
|
||||
Set("batch_size", mailMessageManageBatchSize).
|
||||
Set("batches", chunkMessageManageIDs(messageIDs))
|
||||
for _, batch := range chunkMessageManageIDs(messageIDs) {
|
||||
api = api.POST(mailboxPath(mailboxID, "messages", "batch_trash")).
|
||||
Body(map[string]interface{}{"message_ids": batch})
|
||||
}
|
||||
return api
|
||||
}
|
||||
|
||||
func executeMessageTrash(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
mailboxID := resolveMailboxID(rt)
|
||||
messageIDs, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
summary := messageManageSummary{FailedMessageIDs: []messageManageFailure{}}
|
||||
for _, batch := range chunkMessageManageIDs(messageIDs) {
|
||||
_, err := rt.CallAPITyped("POST", mailboxPath(mailboxID, "messages", "batch_trash"), nil,
|
||||
map[string]interface{}{"message_ids": batch})
|
||||
if err != nil {
|
||||
for _, id := range batch {
|
||||
summary.FailedMessageIDs = append(summary.FailedMessageIDs, messageManageFailure{MessageID: id, Reason: err.Error()})
|
||||
}
|
||||
continue
|
||||
}
|
||||
summary.SuccessMessageIDs = append(summary.SuccessMessageIDs, batch...)
|
||||
}
|
||||
emitMessageManageSummary(rt, summary, false)
|
||||
if len(summary.SuccessMessageIDs) == 0 && len(summary.FailedMessageIDs) > 0 {
|
||||
return mailFailedPreconditionError("all message trash batches failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func mailShortcutTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *by
|
||||
RefreshToken: "test-refresh-token",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(),
|
||||
RefreshExpiresAt: time.Now().Add(24 * time.Hour).UnixMilli(),
|
||||
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly mail:user_mailbox.folder:read",
|
||||
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly",
|
||||
GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(),
|
||||
}
|
||||
if err := auth.SetStoredToken(token); err != nil {
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const mailMessageManageBatchSize = 20
|
||||
|
||||
var messageManageSystemLabels = map[string]string{
|
||||
"UNREAD": "UNREAD",
|
||||
"IMPORTANT": "IMPORTANT",
|
||||
"OTHER": "OTHER",
|
||||
"FLAGGED": "FLAGGED",
|
||||
"READ_RECEIPT_REQUEST": "READ_RECEIPT_REQUEST",
|
||||
}
|
||||
|
||||
var messageManageSystemFolders = map[string]string{
|
||||
"INBOX": "INBOX",
|
||||
"SENT": "SENT",
|
||||
"SPAM": "SPAM",
|
||||
"ARCHIVE": "ARCHIVED",
|
||||
"ARCHIVED": "ARCHIVED",
|
||||
}
|
||||
|
||||
type messageManageSummary struct {
|
||||
SuccessMessageIDs []string `json:"success_message_ids"`
|
||||
FailedMessageIDs []messageManageFailure `json:"failed_message_ids"`
|
||||
}
|
||||
|
||||
type messageManageFailure struct {
|
||||
MessageID string `json:"message_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type validationAPIPlan struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
WillValidate bool `json:"will_validate"`
|
||||
}
|
||||
|
||||
func normalizeMessageManageIDs(raw []string) ([]string, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids is required")
|
||||
}
|
||||
parts, err := splitMessageManageIDTokens(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]string, 0, len(parts))
|
||||
seen := make(map[string]struct{}, len(parts))
|
||||
for i, part := range parts {
|
||||
if part == "" {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
|
||||
}
|
||||
id := strings.TrimSpace(part)
|
||||
if id == "" {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
|
||||
}
|
||||
if id != part {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain leading or trailing whitespace", i+1, part)
|
||||
}
|
||||
if err := validateMessageManageID(id, i); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids is required")
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func splitMessageManageIDTokens(raw []string) ([]string, error) {
|
||||
parts := make([]string, 0, len(raw))
|
||||
for i, token := range raw {
|
||||
for _, r := range token {
|
||||
if unicode.IsSpace(r) || unicode.IsControl(r) {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain whitespace or control characters", i+1, token)
|
||||
}
|
||||
}
|
||||
parts = append(parts, strings.Split(token, ",")...)
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
func validateMessageManageID(id string, index int) error {
|
||||
if len(id) < 16 {
|
||||
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): length must be at least 16 characters", index+1, id)
|
||||
}
|
||||
if strings.Trim(id, "0123456789") == "" {
|
||||
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): numeric primary IDs are not supported; pass the Open API message_id from mail output", index+1, id)
|
||||
}
|
||||
for _, r := range id {
|
||||
if unicode.IsSpace(r) || unicode.IsControl(r) {
|
||||
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain whitespace or control characters", index+1, id)
|
||||
}
|
||||
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
continue
|
||||
}
|
||||
switch r {
|
||||
case '+', '/', '=', '_', '-':
|
||||
continue
|
||||
default:
|
||||
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): contains characters outside the Open API message_id character set", index+1, id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeMessageManageLabels(raw []string, flagName string) ([]string, []string, error) {
|
||||
labels := make([]string, 0, len(raw))
|
||||
custom := make([]string, 0, len(raw))
|
||||
seen := make(map[string]struct{}, len(raw))
|
||||
for i, part := range raw {
|
||||
id := strings.TrimSpace(part)
|
||||
if id == "" {
|
||||
return nil, nil, mailValidationParamError(flagName, "%s entry %d is empty; remove extra commas or provide valid label IDs", flagName, i+1)
|
||||
}
|
||||
if id != part {
|
||||
return nil, nil, mailValidationParamError(flagName, "%s entry %d (%q): must not contain leading or trailing whitespace", flagName, i+1, part)
|
||||
}
|
||||
normalized := id
|
||||
if system, ok := messageManageSystemLabels[strings.ToUpper(id)]; ok {
|
||||
normalized = system
|
||||
} else {
|
||||
custom = append(custom, id)
|
||||
}
|
||||
if _, ok := seen[normalized]; ok {
|
||||
continue
|
||||
}
|
||||
seen[normalized] = struct{}{}
|
||||
labels = append(labels, normalized)
|
||||
}
|
||||
if len(labels) > 20 {
|
||||
return nil, nil, mailValidationParamError(flagName, "%s accepts at most 20 label IDs (got %d)", flagName, len(labels))
|
||||
}
|
||||
return labels, custom, nil
|
||||
}
|
||||
|
||||
func validateLabelIntersection(add, remove []string) error {
|
||||
removeSet := make(map[string]struct{}, len(remove))
|
||||
for _, id := range remove {
|
||||
removeSet[id] = struct{}{}
|
||||
}
|
||||
for _, id := range add {
|
||||
if _, ok := removeSet[id]; ok {
|
||||
return mailValidationParamError("--add-label-ids", "label cannot be both added and removed: %s", id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeMessageManageFolder(raw string) (string, bool, error) {
|
||||
if raw == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
folder := strings.TrimSpace(raw)
|
||||
if folder == "" {
|
||||
return "", false, mailValidationParamError("--add-folder", "--add-folder must not be empty")
|
||||
}
|
||||
if folder != raw {
|
||||
return "", false, mailValidationParamError("--add-folder", "--add-folder %q must not contain leading or trailing whitespace", raw)
|
||||
}
|
||||
if strings.EqualFold(folder, "TRASH") {
|
||||
return "", false, mailValidationParamError("--add-folder", "TRASH is not supported by +message-modify; use +message-trash")
|
||||
}
|
||||
if system, ok := messageManageSystemFolders[strings.ToUpper(folder)]; ok {
|
||||
return system, false, nil
|
||||
}
|
||||
return folder, true, nil
|
||||
}
|
||||
|
||||
func chunkMessageManageIDs(ids []string) [][]string {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
chunks := make([][]string, 0, (len(ids)+mailMessageManageBatchSize-1)/mailMessageManageBatchSize)
|
||||
for start := 0; start < len(ids); start += mailMessageManageBatchSize {
|
||||
end := start + mailMessageManageBatchSize
|
||||
if end > len(ids) {
|
||||
end = len(ids)
|
||||
}
|
||||
chunks = append(chunks, ids[start:end])
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
func validateCustomMessageManageLabels(rt *common.RuntimeContext, mailboxID string, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := validateLabelReadScope(rt); err != nil {
|
||||
return err
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for _, id := range ids {
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
if _, err := rt.CallAPITyped("GET", mailboxPath(mailboxID, "labels", id), nil, nil); err != nil {
|
||||
return mailDecorateProblemMessage(err, "label not found: %s", id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCustomMessageManageFolder(rt *common.RuntimeContext, mailboxID, id string) error {
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
if err := validateFolderReadScope(rt); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := rt.CallAPITyped("GET", mailboxPath(mailboxID, "folders", id), nil, nil); err != nil {
|
||||
return mailDecorateProblemMessage(err, "folder not found: %s", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func messageManageBody(ids, addLabels, removeLabels []string, addFolder string) map[string]interface{} {
|
||||
body := map[string]interface{}{"message_ids": ids}
|
||||
if len(addLabels) > 0 {
|
||||
body["add_label_ids"] = addLabels
|
||||
}
|
||||
if len(removeLabels) > 0 {
|
||||
body["remove_label_ids"] = removeLabels
|
||||
}
|
||||
if addFolder != "" {
|
||||
body["add_folder"] = addFolder
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func messageManageValidationPlan(mailboxID string, customLabels []string, customFolder string) []validationAPIPlan {
|
||||
plans := make([]validationAPIPlan, 0, len(customLabels)+1)
|
||||
seenLabels := map[string]struct{}{}
|
||||
for _, id := range customLabels {
|
||||
if _, ok := seenLabels[id]; ok {
|
||||
continue
|
||||
}
|
||||
seenLabels[id] = struct{}{}
|
||||
plans = append(plans, validationAPIPlan{
|
||||
Method: "GET",
|
||||
Path: mailboxPath(mailboxID, "labels", id),
|
||||
WillValidate: true,
|
||||
})
|
||||
}
|
||||
if customFolder != "" {
|
||||
plans = append(plans, validationAPIPlan{
|
||||
Method: "GET",
|
||||
Path: mailboxPath(mailboxID, "folders", customFolder),
|
||||
WillValidate: true,
|
||||
})
|
||||
}
|
||||
return plans
|
||||
}
|
||||
|
||||
func emitMessageManageSummary(rt *common.RuntimeContext, summary messageManageSummary, noAPICalls bool) {
|
||||
rt.OutFormat(summary, &output.Meta{Count: len(summary.SuccessMessageIDs)}, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "success_message_ids: %d\n", len(summary.SuccessMessageIDs))
|
||||
fmt.Fprintf(w, "failed_message_ids: %d\n", len(summary.FailedMessageIDs))
|
||||
if noAPICalls {
|
||||
fmt.Fprintln(w, "No changes requested; no API calls were made.")
|
||||
}
|
||||
for _, item := range summary.FailedMessageIDs {
|
||||
fmt.Fprintf(w, "- %s: %s\n", item.MessageID, item.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -10,8 +10,6 @@ func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
MailMessage,
|
||||
MailMessages,
|
||||
MailMessageModify,
|
||||
MailMessageTrash,
|
||||
MailThread,
|
||||
MailTriage,
|
||||
MailWatch,
|
||||
|
||||
@@ -715,15 +715,9 @@ 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 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.")
|
||||
appendMarkdownProblemHint(err, "Check whether the target folder or wiki node still exists, and verify the token you passed to the command.")
|
||||
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,7 +9,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -31,19 +30,27 @@ 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 {
|
||||
spec, err := readMarkdownCreateSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validateMarkdownSpec(runtime, spec, true)
|
||||
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)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readMarkdownCreateSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.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"),
|
||||
}
|
||||
fileSize, err := markdownSourceSize(runtime, spec)
|
||||
if err != nil {
|
||||
@@ -64,9 +71,14 @@ var MarkdownCreate = common.Shortcut{
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readMarkdownCreateSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
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"),
|
||||
}
|
||||
fileSize, err := markdownSourceSize(runtime, spec)
|
||||
if err != nil {
|
||||
@@ -103,139 +115,3 @@ 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,173 +446,6 @@ 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())
|
||||
|
||||
|
||||
@@ -28,12 +28,7 @@ import (
|
||||
const minutesDetailLogPrefix = "[minutes +detail]"
|
||||
|
||||
// Error codes from the minutes API.
|
||||
const (
|
||||
minutesDetailProcessingCode = 2091003
|
||||
minutesDetailNoReadPermissionCode = 2091005
|
||||
minutesDetailWaitTimeoutDefault = 300
|
||||
minutesDetailWaitIntervalDefault = 15
|
||||
)
|
||||
const minutesDetailNoReadPermissionCode = 2091005
|
||||
|
||||
var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)
|
||||
|
||||
@@ -45,31 +40,19 @@ var scopesDetailMinuteTokens = []string{
|
||||
// minuteDetailItem represents a single minute detail result.
|
||||
type minuteDetailItem struct {
|
||||
MinuteToken string `json:"minute_token"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Title string `json:"title"`
|
||||
NoteID string `json:"note_id"`
|
||||
Artifacts map[string]any `json:"artifacts,omitempty"`
|
||||
Retryable bool `json:"retryable,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
NextCommand string `json:"next_command,omitempty"`
|
||||
}
|
||||
|
||||
// fetchMinuteDetail queries a single minute's metadata and selected artifacts.
|
||||
func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) *minuteDetailItem {
|
||||
artifactFlags := requestedMinutesDetailArtifactFlags(runtime)
|
||||
waitReady := runtime.Bool("wait-ready")
|
||||
waitTimeout, waitInterval := minutesDetailWaitConfig(runtime)
|
||||
|
||||
data, err := callMinutesDetailAPIUntilReady(ctx, runtime, waitReady, waitTimeout, waitInterval, func() (map[string]interface{}, error) {
|
||||
return runtime.CallAPITyped(http.MethodGet,
|
||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil)
|
||||
})
|
||||
data, err := runtime.CallAPITyped(http.MethodGet,
|
||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil)
|
||||
if err != nil {
|
||||
result := &minuteDetailItem{MinuteToken: minuteToken}
|
||||
if isMinutesDetailProcessingError(err) {
|
||||
markMinutesDetailProcessing(result, minuteToken, artifactFlags, "minute metadata is still being generated")
|
||||
} else if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailNoReadPermissionCode {
|
||||
if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailNoReadPermissionCode {
|
||||
result.Error = fmt.Sprintf("No read permission for minute %s. Ask the minute owner for minute file read permission", minuteToken)
|
||||
} else {
|
||||
result.Error = fmt.Sprintf("failed to query minute: %v", err)
|
||||
@@ -98,16 +81,10 @@ func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minu
|
||||
needKeyword := runtime.Bool("keyword")
|
||||
|
||||
if needSummary || needTodo || needChapter || needTranscript || needKeyword {
|
||||
artData, err := callMinutesDetailAPIUntilReady(ctx, runtime, waitReady, waitTimeout, waitInterval, func() (map[string]interface{}, error) {
|
||||
return runtime.CallAPITyped(http.MethodGet,
|
||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil)
|
||||
})
|
||||
artData, err := runtime.CallAPITyped(http.MethodGet,
|
||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil)
|
||||
if err != nil {
|
||||
if isMinutesDetailProcessingError(err) {
|
||||
markMinutesDetailProcessing(result, minuteToken, artifactFlags, "minute artifacts are still being generated")
|
||||
} else {
|
||||
result.Error = fmt.Sprintf("failed to query minute artifacts: %v", err)
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "%s failed to fetch artifacts for %s: %v\n", minutesDetailLogPrefix, minuteToken, err)
|
||||
} else {
|
||||
artifacts := make(map[string]any)
|
||||
if needSummary {
|
||||
@@ -156,78 +133,6 @@ func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minu
|
||||
return result
|
||||
}
|
||||
|
||||
func isMinutesDetailProcessingError(err error) bool {
|
||||
if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailProcessingCode {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func minutesDetailWaitConfig(runtime *common.RuntimeContext) (time.Duration, time.Duration) {
|
||||
timeoutSeconds, intervalSeconds := normalizeMinutesDetailWaitSeconds(runtime.Int("wait-timeout-seconds"), runtime.Int("wait-interval-seconds"))
|
||||
return time.Duration(timeoutSeconds) * time.Second, time.Duration(intervalSeconds) * time.Second
|
||||
}
|
||||
|
||||
func normalizeMinutesDetailWaitSeconds(timeoutSeconds, intervalSeconds int) (int, int) {
|
||||
if timeoutSeconds <= 0 {
|
||||
timeoutSeconds = minutesDetailWaitTimeoutDefault
|
||||
}
|
||||
if intervalSeconds <= 0 {
|
||||
intervalSeconds = minutesDetailWaitIntervalDefault
|
||||
}
|
||||
return timeoutSeconds, intervalSeconds
|
||||
}
|
||||
|
||||
func callMinutesDetailAPIUntilReady(ctx context.Context, runtime *common.RuntimeContext, waitReady bool, timeout, interval time.Duration, call func() (map[string]interface{}, error)) (map[string]interface{}, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
data, err := call()
|
||||
if err == nil || !waitReady || !isMinutesDetailProcessingError(err) {
|
||||
return data, err
|
||||
}
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return nil, ctxErr
|
||||
}
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 || interval > remaining {
|
||||
return nil, err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "%s minute is still processing; retrying in %s\n", minutesDetailLogPrefix, interval)
|
||||
timer := time.NewTimer(interval)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return nil, ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requestedMinutesDetailArtifactFlags(runtime *common.RuntimeContext) []string {
|
||||
var flags []string
|
||||
for _, flag := range []string{"summary", "todo", "chapter", "keyword", "transcript"} {
|
||||
if runtime.Bool(flag) {
|
||||
flags = append(flags, "--"+flag)
|
||||
}
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
func markMinutesDetailProcessing(result *minuteDetailItem, minuteToken string, artifactFlags []string, reason string) {
|
||||
result.Status = "processing"
|
||||
result.Retryable = true
|
||||
result.Error = reason
|
||||
result.Hint = "The minute is still being generated. Retry later, or rerun the next_command to wait until it is ready."
|
||||
result.NextCommand = minutesDetailNextCommand(minuteToken, artifactFlags)
|
||||
}
|
||||
|
||||
func minutesDetailNextCommand(minuteToken string, artifactFlags []string) string {
|
||||
parts := []string{"lark-cli", "minutes", "+detail", "--minute-tokens", minuteToken}
|
||||
parts = append(parts, artifactFlags...)
|
||||
parts = append(parts, "--wait-ready")
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// saveDetailTranscript persists transcript bytes to the canonical artifact path.
|
||||
// With --output-dir, transcripts land under <output-dir>/artifact-<title>-<token>/
|
||||
// to mirror the legacy `vc +notes` layout. Otherwise falls back to the default
|
||||
@@ -296,9 +201,6 @@ var MinutesDetail = common.Shortcut{
|
||||
{Name: "keyword", Type: "bool", Desc: "include keywords"},
|
||||
{Name: "output-dir", Desc: "output directory for transcript files (default: ./minutes/{minute_token}/)"},
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing transcript files"},
|
||||
{Name: "wait-ready", Type: "bool", Desc: "wait until minute metadata/artifacts are ready", Hidden: true},
|
||||
{Name: "wait-timeout-seconds", Type: "int", Default: "300", Desc: "maximum seconds to wait for readiness", Hidden: true},
|
||||
{Name: "wait-interval-seconds", Type: "int", Default: "15", Desc: "seconds between readiness checks", Hidden: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
tokens := common.SplitCSV(runtime.Str("minute-tokens"))
|
||||
@@ -380,15 +282,8 @@ var MinutesDetail = common.Shortcut{
|
||||
for _, r := range results {
|
||||
row := map[string]interface{}{"minute_token": r.MinuteToken}
|
||||
if r.Error != "" {
|
||||
if r.Status == "processing" {
|
||||
row["status"] = "PROCESSING"
|
||||
} else {
|
||||
row["status"] = "FAIL"
|
||||
}
|
||||
row["status"] = "FAIL"
|
||||
row["error"] = r.Error
|
||||
if r.NextCommand != "" {
|
||||
row["next_command"] = r.NextCommand
|
||||
}
|
||||
} else {
|
||||
row["status"] = "OK"
|
||||
row["title"] = r.Title
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -109,17 +108,6 @@ func detailArtifactsStub(token, transcript string) *httpmock.Stub {
|
||||
}
|
||||
}
|
||||
|
||||
func detailProcessingStub(path string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: path,
|
||||
Body: map[string]interface{}{
|
||||
"code": 2091003,
|
||||
"msg": "minute is processing",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Validation_MissingMinuteTokens(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--as", "user"}, f, nil)
|
||||
@@ -184,34 +172,6 @@ func TestDetail_DryRun_WithArtifactFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_HiddenWaitFlags(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
parent := &cobra.Command{Use: "minutes"}
|
||||
MinutesDetail.Mount(parent, f)
|
||||
parent.SetOut(stdout)
|
||||
parent.SetArgs([]string{"+detail", "--help"})
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if err := parent.Execute(); err != nil {
|
||||
t.Fatalf("help failed: %v", err)
|
||||
}
|
||||
help := stdout.String()
|
||||
for _, hidden := range []string{"wait-ready", "wait-timeout-seconds", "wait-interval-seconds"} {
|
||||
if strings.Contains(help, hidden) {
|
||||
t.Fatalf("hidden flag %q should not appear in help:\n%s", hidden, help)
|
||||
}
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||
"+detail", "--minute-tokens", "tok001", "--summary", "--wait-ready",
|
||||
"--wait-timeout-seconds", "0", "--wait-interval-seconds", "0", "--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("hidden wait flags should parse: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execute tests with mocked HTTP
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -395,136 +355,6 @@ func TestDetail_Execute_MinuteNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Execute_MetadataProcessing(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokpending"))
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokpending", "--summary", "--as", "user"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected partial failure error")
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
m := firstDetailMinute(t, stdout.Bytes())
|
||||
if m["status"] != "processing" {
|
||||
t.Fatalf("status = %v, want processing", m["status"])
|
||||
}
|
||||
if m["retryable"] != true {
|
||||
t.Fatalf("retryable = %v, want true", m["retryable"])
|
||||
}
|
||||
if !strings.Contains(fmt.Sprint(m["next_command"]), "minutes +detail --minute-tokens tokpending --summary --wait-ready") {
|
||||
t.Fatalf("next_command = %v", m["next_command"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Execute_ArtifactsProcessing(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(detailMinuteGetStub("tokartpending", "note_pending", "Pending Artifacts"))
|
||||
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokartpending/artifacts"))
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokartpending", "--summary", "--todo", "--as", "user"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected partial failure error")
|
||||
}
|
||||
m := firstDetailMinute(t, stdout.Bytes())
|
||||
if m["status"] != "processing" {
|
||||
t.Fatalf("status = %v, want processing", m["status"])
|
||||
}
|
||||
if m["title"] != "Pending Artifacts" || m["note_id"] != "note_pending" {
|
||||
t.Fatalf("metadata should be preserved on artifacts processing, got title=%v note_id=%v", m["title"], m["note_id"])
|
||||
}
|
||||
if !strings.Contains(fmt.Sprint(m["next_command"]), "--summary --todo --wait-ready") {
|
||||
t.Fatalf("next_command = %v", m["next_command"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_WaitReady_MetadataEventuallyReady(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokwaitmeta"))
|
||||
reg.Register(detailMinuteGetStub("tokwaitmeta", "", "Ready Metadata"))
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||
"+detail", "--minute-tokens", "tokwaitmeta", "--wait-ready",
|
||||
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
m := firstDetailMinute(t, stdout.Bytes())
|
||||
if m["title"] != "Ready Metadata" {
|
||||
t.Fatalf("title = %v, want Ready Metadata", m["title"])
|
||||
}
|
||||
if _, ok := m["artifacts"]; ok {
|
||||
t.Fatal("artifacts should not be fetched without artifact flags")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_WaitReady_ArtifactsEventuallyReady(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(detailMinuteGetStub("tokwaitart", "note_wait", "Ready Artifacts"))
|
||||
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokwaitart/artifacts"))
|
||||
reg.Register(detailArtifactsStub("tokwaitart", ""))
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||
"+detail", "--minute-tokens", "tokwaitart", "--summary", "--wait-ready",
|
||||
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
m := firstDetailMinute(t, stdout.Bytes())
|
||||
arts, _ := m["artifacts"].(map[string]any)
|
||||
if arts == nil {
|
||||
t.Fatal("expected artifacts")
|
||||
}
|
||||
if arts["summary"] != "Test summary content" {
|
||||
t.Fatalf("summary = %v", arts["summary"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_WaitReady_TimeoutUsesProcessingResult(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(detailMinuteGetStub("toktimeout", "note_timeout", "Timeout Artifacts"))
|
||||
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/toktimeout/artifacts"))
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||
"+detail", "--minute-tokens", "toktimeout", "--summary", "--wait-ready",
|
||||
"--wait-timeout-seconds", "1", "--wait-interval-seconds", "2", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected partial failure error")
|
||||
}
|
||||
m := firstDetailMinute(t, stdout.Bytes())
|
||||
if m["status"] != "processing" || m["title"] != "Timeout Artifacts" || m["note_id"] != "note_timeout" {
|
||||
t.Fatalf("timeout should preserve processing status and metadata, got %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_WaitReady_DoesNotPollNonProcessingErrors(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
var callCount int
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/minutes/v1/minutes/tokmissing",
|
||||
Body: map[string]interface{}{"code": 2091004, "msg": "not found"},
|
||||
Reusable: true,
|
||||
OnMatch: func(req *http.Request) { callCount++ },
|
||||
})
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||
"+detail", "--minute-tokens", "tokmissing", "--wait-ready",
|
||||
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected partial failure error")
|
||||
}
|
||||
if callCount != 1 {
|
||||
t.Fatalf("non-processing error should not be retried, callCount=%d", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure function tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -548,36 +378,6 @@ func TestValidMinuteTokenDetail(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMinutesDetailWaitSeconds(t *testing.T) {
|
||||
timeout, interval := normalizeMinutesDetailWaitSeconds(0, 0)
|
||||
if timeout != minutesDetailWaitTimeoutDefault || interval != minutesDetailWaitIntervalDefault {
|
||||
t.Fatalf("normalize(0,0) = (%d,%d), want defaults (%d,%d)", timeout, interval, minutesDetailWaitTimeoutDefault, minutesDetailWaitIntervalDefault)
|
||||
}
|
||||
timeout, interval = normalizeMinutesDetailWaitSeconds(-1, -2)
|
||||
if timeout != minutesDetailWaitTimeoutDefault || interval != minutesDetailWaitIntervalDefault {
|
||||
t.Fatalf("normalize(negative) = (%d,%d), want defaults", timeout, interval)
|
||||
}
|
||||
timeout, interval = normalizeMinutesDetailWaitSeconds(9, 3)
|
||||
if timeout != 9 || interval != 3 {
|
||||
t.Fatalf("normalize(9,3) = (%d,%d)", timeout, interval)
|
||||
}
|
||||
}
|
||||
|
||||
func firstDetailMinute(t *testing.T, raw []byte) map[string]any {
|
||||
t.Helper()
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
t.Fatalf("failed to parse output: %v\n%s", err, string(raw))
|
||||
}
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
minutes, _ := data["minutes"].([]any)
|
||||
if len(minutes) != 1 {
|
||||
t.Fatalf("expected 1 minute, got %d in %s", len(minutes), string(raw))
|
||||
}
|
||||
m, _ := minutes[0].(map[string]any)
|
||||
return m
|
||||
}
|
||||
|
||||
// chdirForDetailTest switches cwd to a temp dir for the test.
|
||||
func chdirForDetailTest(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
@@ -5,8 +5,6 @@ package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
@@ -67,25 +65,8 @@ var MinutesUpload = common.Shortcut{
|
||||
outData := map[string]interface{}{
|
||||
"minute_url": minuteURL,
|
||||
}
|
||||
if minuteToken := extractUploadedMinuteToken(minuteURL); minuteToken != "" {
|
||||
outData["minute_token"] = minuteToken
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func extractUploadedMinuteToken(minuteURL string) string {
|
||||
u, err := url.Parse(minuteURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(strings.TrimRight(u.Path, "/"), "/")
|
||||
for i, part := range parts {
|
||||
if part == "minutes" && i+1 < len(parts) {
|
||||
return parts[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -143,28 +143,4 @@ func TestMinutesUpload_Execute(t *testing.T) {
|
||||
if dataMap["minute_url"] != "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c" {
|
||||
t.Errorf("expected minute_url https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c, got %v", dataMap["minute_url"])
|
||||
}
|
||||
if dataMap["minute_token"] != "obcnq3b9jl72l83w4f149w9c" {
|
||||
t.Errorf("expected minute_token obcnq3b9jl72l83w4f149w9c, got %v", dataMap["minute_token"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractUploadedMinuteToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
want string
|
||||
}{
|
||||
{name: "standard", url: "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c", want: "obcnq3b9jl72l83w4f149w9c"},
|
||||
{name: "query", url: "https://sample.feishu.cn/minutes/obcn123?from=upload", want: "obcn123"},
|
||||
{name: "trailing slash", url: "https://sample.feishu.cn/minutes/obcn123/", want: "obcn123"},
|
||||
{name: "invalid", url: "://bad", want: ""},
|
||||
{name: "no minutes path", url: "https://sample.feishu.cn/docx/abc", want: ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := extractUploadedMinuteToken(tt.url); got != tt.want {
|
||||
t.Fatalf("extractUploadedMinuteToken(%q) = %q, want %q", tt.url, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ package wiki
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -27,17 +26,3 @@ 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,14 +5,12 @@ 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"
|
||||
@@ -132,147 +130,6 @@ 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())
|
||||
|
||||
@@ -280,14 +137,14 @@ func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"space_id": "7211568716812369922",
|
||||
"space_id": "space_123",
|
||||
"node_token": "wik_node_1",
|
||||
"obj_token": "docx_1",
|
||||
"obj_type": "docx",
|
||||
@@ -297,7 +154,7 @@ func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
|
||||
"has_child": true,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"space_id": "7211568716812369922",
|
||||
"space_id": "space_123",
|
||||
"node_token": "wik_node_2",
|
||||
"obj_token": "docx_2",
|
||||
"obj_type": "docx",
|
||||
@@ -313,7 +170,7 @@ func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||
"+node-list", "--space-id", "7211568716812369922", "--as", "bot",
|
||||
"+node-list", "--space-id", "space_123", "--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
@@ -354,14 +211,14 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes?page_size=50&parent_node_token=wik_parent",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_123/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": "7211568716812369922",
|
||||
"space_id": "space_123",
|
||||
"node_token": "wik_child",
|
||||
"obj_token": "docx_child",
|
||||
"obj_type": "docx",
|
||||
@@ -378,7 +235,7 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||
"+node-list", "--space-id", "7211568716812369922", "--parent-node-token", "wik_parent", "--as", "bot",
|
||||
"+node-list", "--space-id", "space_123", "--parent-node-token", "wik_parent", "--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
@@ -429,7 +286,7 @@ func TestWikiNodeListResolvesMyLibraryForUser(t *testing.T) {
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"space": map[string]interface{}{
|
||||
"space_id": "7211568716812369923",
|
||||
"space_id": "space_personal_42",
|
||||
"name": "My Library",
|
||||
"space_type": "my_library",
|
||||
},
|
||||
@@ -439,14 +296,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/7211568716812369923/nodes",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_personal_42/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"space_id": "7211568716812369923",
|
||||
"space_id": "space_personal_42",
|
||||
"node_token": "wik_personal_1",
|
||||
"title": "Personal Note",
|
||||
},
|
||||
@@ -477,8 +334,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"] != "7211568716812369923" {
|
||||
t.Fatalf("nodes[0].space_id = %v, want 7211568716812369923", envelope.Data.Nodes[0]["space_id"])
|
||||
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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -901,21 +758,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/7211568716812369922/nodes",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_123/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": "7211568716812369922", "node_token": "wik_1", "title": "First"},
|
||||
map[string]interface{}{"space_id": "space_123", "node_token": "wik_1", "title": "First"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||
"+node-list", "--space-id", "7211568716812369922", "--as", "bot",
|
||||
"+node-list", "--space-id", "space_123", "--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
@@ -945,14 +802,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/7211568716812369922/nodes",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"space_id": "7211568716812369922",
|
||||
"space_id": "space_123",
|
||||
"node_token": "wik_1",
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docx_1",
|
||||
@@ -965,7 +822,7 @@ func TestWikiNodeListPrettyFormatRendersFields(t *testing.T) {
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||
"+node-list", "--space-id", "7211568716812369922", "--format", "pretty", "--as", "bot",
|
||||
"+node-list", "--space-id", "space_123", "--format", "pretty", "--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
|
||||
@@ -48,19 +48,27 @@ 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 {
|
||||
if _, err := readWikiNodeListSpec(runtime); err != nil {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
return validateWikiListPagination(runtime, wikiNodeListMaxPageSize)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readWikiNodeListSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
spaceID := strings.TrimSpace(runtime.Str("space-id"))
|
||||
params := map[string]interface{}{"page_size": runtime.Int("page-size")}
|
||||
if spec.ParentNodeToken != "" {
|
||||
params["parent_node_token"] = spec.ParentNodeToken
|
||||
if pt := strings.TrimSpace(runtime.Str("parent-node-token")); pt != "" {
|
||||
params["parent_node_token"] = pt
|
||||
}
|
||||
if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" {
|
||||
params["page_token"] = pt
|
||||
@@ -72,7 +80,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 spec.SpaceID == wikiMyLibrarySpaceID {
|
||||
if spaceID == wikiMyLibrarySpaceID {
|
||||
return d.
|
||||
Desc("2-step orchestration: resolve my_library -> list nodes").
|
||||
GET("/open-apis/wiki/v2/spaces/my_library").
|
||||
@@ -83,17 +91,13 @@ var WikiNodeList = common.Shortcut{
|
||||
Set("space_id", "<resolved_space_id>")
|
||||
}
|
||||
return d.
|
||||
GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spec.SpaceID))).
|
||||
GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spaceID))).
|
||||
Params(params).
|
||||
Set("space_id", spec.SpaceID)
|
||||
Set("space_id", spaceID)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
warnIfConflictingPagingFlags(runtime)
|
||||
spec, err := readWikiNodeListSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spaceID := spec.SpaceID
|
||||
spaceID := strings.TrimSpace(runtime.Str("space-id"))
|
||||
|
||||
// Resolve the my_library alias to the per-user real space_id before
|
||||
// listing, so the subsequent request hits a concrete space endpoint.
|
||||
@@ -106,7 +110,7 @@ var WikiNodeList = common.Shortcut{
|
||||
spaceID = resolved
|
||||
}
|
||||
|
||||
nodes, hasMore, nextToken, err := fetchWikiNodes(runtime, spaceID, spec.ParentNodeToken)
|
||||
nodes, hasMore, nextToken, err := fetchWikiNodes(runtime, spaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -123,104 +127,10 @@ var WikiNodeList = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
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) {
|
||||
func fetchWikiNodes(runtime *common.RuntimeContext, spaceID 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")
|
||||
|
||||
@@ -243,7 +153,7 @@ func fetchWikiNodes(runtime *common.RuntimeContext, spaceID, parentNodeToken str
|
||||
}
|
||||
data, err := runtime.CallAPITyped("GET", apiPath, params, nil)
|
||||
if err != nil {
|
||||
return nil, false, "", wikiNodeListProblem(err, runtime)
|
||||
return nil, false, "", err
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
for _, item := range items {
|
||||
@@ -267,36 +177,6 @@ func fetchWikiNodes(runtime *common.RuntimeContext, spaceID, parentNodeToken str
|
||||
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"),
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"testing"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
@@ -586,15 +585,11 @@ func TestProxyHandler_StripsClientSuppliedAuthHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildAllowedHosts(t *testing.T) {
|
||||
feishu := core.Endpoints{
|
||||
Open: "https://open.feishu.cn",
|
||||
Accounts: "https://accounts.feishu.cn",
|
||||
MCP: "https://mcp.feishu.cn",
|
||||
feishu := struct{ Open, Accounts, MCP string }{
|
||||
"https://open.feishu.cn", "https://accounts.feishu.cn", "https://mcp.feishu.cn",
|
||||
}
|
||||
lark := core.Endpoints{
|
||||
Open: "https://open.larksuite.com",
|
||||
Accounts: "https://accounts.larksuite.com",
|
||||
MCP: "https://mcp.larksuite.com",
|
||||
lark := struct{ Open, Accounts, MCP string }{
|
||||
"https://open.larksuite.com", "https://accounts.larksuite.com", "https://mcp.larksuite.com",
|
||||
}
|
||||
hosts := buildAllowedHosts(feishu, lark)
|
||||
// feishu hosts
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
|
||||
1. `+triage --from spam@x.com` → 列出 N 条结果
|
||||
2. 展示:"将删除 N 封邮件(发件人 spam@x.com,主题:…),确认?"
|
||||
3. 用户确认后 → `+message-trash --message-ids ... --yes`
|
||||
3. 用户确认后 → `*.batch_trash`
|
||||
|
||||
## 身份选择:优先使用 user 身份
|
||||
|
||||
@@ -82,13 +82,12 @@
|
||||
1. **确认身份** — 首次操作邮箱前先调用 `lark-cli mail user_mailboxes profile --params '{"user_mailbox_id":"me"}'` 获取当前用户的真实邮箱地址(`primary_email_address`),不要通过系统用户名猜测。后续判断"发件人是否为用户本人"时以此地址为准。
|
||||
2. **浏览** — `+triage` 查看收件箱摘要,获取 `message_id` / `thread_id`
|
||||
3. **阅读** — `+message` 读单封邮件,`+thread` 读整个会话
|
||||
4. **整理** — 标签、已读/未读状态和移动文件夹优先用 `+message-modify`;软删除优先用 `+message-trash`
|
||||
5. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
6. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
7. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送
|
||||
8. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
|
||||
9. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
|
||||
10. **已读回执** —
|
||||
4. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
5. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
6. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送
|
||||
7. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
|
||||
8. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
|
||||
9. **已读回执** —
|
||||
- **请求回执(写信侧)**:`--request-receipt` 仅在**用户显式要求**时添加,**不要从 subject / body 内容推断意图**。
|
||||
- **响应回执(拉信侧)**:拉信看到 `label_ids` 含 `READ_RECEIPT_REQUEST`(或 `-607`)时,**必须先问用户**是否回执(不要自动回执,涉及隐私)。用户同意 → `+send-receipt` 响应;用户不同意但想消掉提示 → `+decline-receipt` 只清本地标签、不发邮件。
|
||||
|
||||
@@ -418,7 +417,7 @@ lark-cli mail +message --message-id <id>
|
||||
|
||||
## 原生 API 调用规则
|
||||
|
||||
没有 Shortcut 覆盖的操作才使用原生 API。标签、已读状态、移动文件夹优先使用 `+message-modify`;软删除优先使用 `+message-trash`。调用步骤以本节为准(API Resources 章节的 resource/method 列表可辅助查阅)。
|
||||
没有 Shortcut 覆盖的操作才使用原生 API。调用步骤以本节为准(API Resources 章节的 resource/method 列表可辅助查阅)。
|
||||
|
||||
### Step 1 — 用 `-h` 确定要调用的 API(必须,不可跳过)
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ metadata:
|
||||
|
||||
开始前先读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)(认证、权限处理)。
|
||||
|
||||
**CRITICAL — 凡涉及预约日程/会议室、调整时间或查询/搜索会议室,第一步 MUST 读 [`references/lark-calendar-schedule-meeting.md`](references/lark-calendar-schedule-meeting.md)。仅编辑字段(改标题/描述)或增删参会人(不涉及时间和会议室)时可跳过,直接读 [`references/lark-calendar-update.md`](references/lark-calendar-update.md)。**
|
||||
**CRITICAL — 凡涉及预约日程/会议或查询/搜索会议室,第一步 MUST 读 [`references/lark-calendar-schedule-meeting.md`](references/lark-calendar-schedule-meeting.md)。禁止跳过此步直接调用 API 或 Shortcut!**
|
||||
|
||||
## 身份
|
||||
|
||||
@@ -30,80 +30,26 @@ lark-cli calendar +agenda --as user
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| `+agenda` | 查看日程安排(默认今天) |
|
||||
| [`+agenda`](references/lark-calendar-agenda.md) | 查看日程安排(默认今天) |
|
||||
| [`+search-event`](references/lark-calendar-search-event.md) | 按关键词、时间范围和参会人搜索日程, 仅返回 日程ID/主题/时间等信息,详情需走 `events get` |
|
||||
| [`+meeting`](references/lark-calendar-meeting.md) | 通过日程事件 ID 获取关联的视频会议信息(meeting_id、meeting_note),日程开过视频会议才会有meeting_id |
|
||||
| [`+create`](references/lark-calendar-create.md) | 创建日程并邀请参会人(ISO 8601 时间) |
|
||||
| [`+update`](references/lark-calendar-update.md) | 更新既有日程字段,或独立增量添加/移除参会人和会议室 |
|
||||
| `+freebusy` | 查询用户主日历的忙闲信息和 RSVP 状态(纯查询场景;预约场景走 `+suggestion`) |
|
||||
| [`+freebusy`](references/lark-calendar-freebusy.md) | 查询用户主日历的忙闲信息和 RSVP 状态 |
|
||||
| [`+room-find`](references/lark-calendar-room-find.md) | 针对一个或多个**明确的**时间块查找可用会议室(无明确时间时禁止直接调用,需先走 +suggestion) |
|
||||
| [`+rsvp`](references/lark-calendar-rsvp.md) | 回复日程(接受/拒绝/待定) |
|
||||
| [`+suggestion`](references/lark-calendar-suggestion.md) | 根据非明确时间或一段时间范围,推荐多个可用时间块方案 |
|
||||
|
||||
### `+get` — 单日程详情
|
||||
|
||||
通过 `calendar_id` + `event_id` 获取**单个日程**详情。
|
||||
|
||||
```bash
|
||||
# calendar_id不传,默认primary
|
||||
lark-cli calendar +get --calendar-id <calendar_id> --event-id <event_id>
|
||||
```
|
||||
|
||||
### `+search-event` — 按关键词、时间范围和参会人搜索日程
|
||||
|
||||
仅返回基础字段(`event_id`/`summary`/`start`/`end` 等),需要详情请走 `+get`。
|
||||
|
||||
```bash
|
||||
# query 按关键词 可选
|
||||
# start/end 按时间范围(ISO 8601 或 YYYY-MM-DD)可选
|
||||
# attendee-ids 按参会人(自动识别 ou_ 用户 / oc_ 群聊 / omm_ 会议室前缀)可选
|
||||
# page-token 分页游标,用于继续翻页 可选
|
||||
# page-size 每页数量,默认 30 可选
|
||||
lark-cli calendar +search-event --query "周会" --start 2026-04-20 --end 2026-04-27 --attendee-ids "ou_user1,oc_chat1,omm_room1" --page-token <page_token> --page-size 30
|
||||
```
|
||||
|
||||
### `+agenda` — 查看近期日程安排
|
||||
|
||||
默认查询当天。结果应整理为按日期分组、按开始时间升序的易读时间线。
|
||||
|
||||
```bash
|
||||
# start/end 时间范围(ISO 8601 / YYYY-MM-DD / Unix 秒),均可选;默认当天
|
||||
# calendar-id 日历 ID(默认primary)可选
|
||||
lark-cli calendar +agenda --start 2026-03-10 --end 2026-03-17 --calendar-id <calendar_id>
|
||||
```
|
||||
|
||||
注意:
|
||||
- 已取消的日程自动过滤;无日程时直接告知"日程清空"。
|
||||
- 时间范围超过 40 天会自动拆分查询并合并结果。
|
||||
|
||||
### `+freebusy` — 查询主日历忙闲时段和 RSVP 状态
|
||||
|
||||
仅返回忙碌时段起止时间,不含日程标题等隐私信息;其他订阅日历不在范围内。
|
||||
|
||||
```bash
|
||||
# start/end 时间范围(ISO 8601 / YYYY-MM-DD / Unix 秒),均可选;默认当天
|
||||
# user-id 目标用户 open_id(ou_ 前缀)可选;默认当前登录用户,bot 身份必须显式指定
|
||||
lark-cli calendar +freebusy --start 2026-03-11 --end 2026-03-12 --user-id ou_xxx
|
||||
```
|
||||
|
||||
用法提示:
|
||||
- **仅判断是否有空** → `+freebusy`;**需要日程详情** → `+agenda`。
|
||||
- 检查多人可用性:分别调用并对比,找共同空闲。
|
||||
- 预约/改约场景下,调用规则(参与人过多、含群组、来自 `+suggestion` 等)详见 [schedule-clear-time.md § 查询忙闲](references/lark-calendar-schedule-clear-time.md#2-查询忙闲)。
|
||||
|
||||
## 前置条件路由
|
||||
|
||||
| 场景 | 前置要求 |
|
||||
|------|----------|
|
||||
| 预约日程/会议、调整时间、查会议室 | 先读 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) |
|
||||
| 仅编辑字段(标题/描述)或增删参会人 | 先定位 `event_id`,再读 [lark-calendar-update.md](references/lark-calendar-update.md) |
|
||||
| 编辑已有日程(涉及时间或会议室) | 先定位目标日程 `event_id`;若是重复性日程,必须定位到具体实例的 `event_id`(禁止使用原重复日程 ID) |
|
||||
| 预约日程/会议、查会议室 | 先读 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) |
|
||||
| 编辑已有日程 | 先定位目标日程 `event_id` |
|
||||
| 编辑/删除重复性日程 | 先读 [重复性日程操作规范](references/lark-calendar-recurring.md),按操作范围(仅此次/全部/此次及后续)执行 |
|
||||
| 删除/修改后验证 | 等待 2 秒再查询(API 最终一致性),不要告知用户你等待了 |
|
||||
| 调用任何 Shortcut | 先读其对应 reference 文档 |
|
||||
|
||||
## 写操作反馈
|
||||
|
||||
创建、更新、删除、RSVP 等写操作完成后,直接基于命令返回结果反馈用户;不要为了“确认是否生效”主动发起二次查询。只有用户明确要求复查,或命令返回信息不足以回答用户问题时,才需要再查询。
|
||||
|
||||
## 核心概念
|
||||
|
||||
- **日程实例(Instance)**:重复性日程展开后的具体时间实例。「仅此次」操作时使用具体实例的 `event_id`;「全部」或「此次及后续」操作时需对原重复性日程操作(使用原日程 `event_id`),并按需处理例外。
|
||||
@@ -126,8 +72,7 @@ lark-cli calendar +freebusy --start 2026-03-11 --end 2026-03-12 --user-id ou_xxx
|
||||
| 按关键词搜索日程 | 本 skill(`+search-event`) |
|
||||
| 从日程获取关联的视频会议 ID 或用户绑定的会议纪要文档 | 本 skill(`+meeting`) |
|
||||
| 从日程进一步拿 AI 智能纪要 / 逐字稿 / 妙记产物 | 先 `+meeting` 取 `meeting_id`,再 [`vc +detail`](../lark-vc/references/lark-vc-detail.md) → [`note +detail`](../lark-note/references/lark-note-detail.md) / [`minutes +detail`](../lark-minutes/references/lark-minutes-detail.md) |
|
||||
| 预约/改约日程、调整时间、添加/更换会议室、查会议室 | 先判断新建 vs 编辑,再进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md) |
|
||||
| 仅编辑日程字段(标题/描述)或增删参会人(不涉及时间和会议室) | 先定位 `event_id`,再读 [+update](references/lark-calendar-update.md) 执行变更 |
|
||||
| 预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间 | 先判断新建 vs 编辑,再进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md) |
|
||||
| 编辑/删除重复性日程(「改这个重复日程」「删掉后面的」「全部取消」等) | 先读 [重复性日程操作规范](references/lark-calendar-recurring.md),确认操作范围后执行 |
|
||||
|
||||
## 任务类型分流
|
||||
@@ -145,7 +90,7 @@ lark-cli calendar +freebusy --start 2026-03-11 --end 2026-03-12 --user-id ou_xxx
|
||||
|
||||
## 会议室规则
|
||||
|
||||
- 凡是"预定/查询/搜索可用会议室",都必须进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md),会议室参数规范详见 [+room-find](references/lark-calendar-room-find.md)。
|
||||
- 凡是"预定/查询/搜索可用会议室",都必须进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md)。
|
||||
- `+room-find` 的时间输入必须是确定时间块,不能是时间区间搜索。
|
||||
- 用户仅要求"查会议室"但未提供明确时间时,必须先调用 `+suggestion` 获取可用时间块,再将时间块交给 `+room-find`。严禁猜测时间盲目调用。
|
||||
- 编辑已有日程时,"添加会议室"默认是增量语义,保留已有会议室;只有用户明确说"更换会议室""移除会议室"时才删除旧会议室。
|
||||
@@ -153,45 +98,42 @@ lark-cli calendar +freebusy --start 2026-03-11 --end 2026-03-12 --user-id ou_xxx
|
||||
## API Resources
|
||||
|
||||
```bash
|
||||
# 通用调用格式
|
||||
lark-cli calendar <resource> <method> [flags]
|
||||
|
||||
# 查询用户主日历
|
||||
lark-cli calendar calendars primary
|
||||
|
||||
# 获取日程分享链接
|
||||
lark-cli calendar events share_info --calendar-id <calendar_id> --event-id <event_id>
|
||||
|
||||
# 删除日程
|
||||
lark-cli calendar events delete --calendar-id <calendar_id> --event-id <event_id>
|
||||
```
|
||||
|
||||
> `calendar_id` 可以直接传 `primary`,代表当前调用身份的主日历 ID。
|
||||
### calendars
|
||||
|
||||
### 查询资源的方法列表以及方法的使用方式
|
||||
- `create` — 创建共享日历
|
||||
- `delete` — 删除共享日历
|
||||
- `get` — 查询日历信息
|
||||
- `list` — 查询日历列表
|
||||
- `patch` — 更新日历信息
|
||||
- `primary` — 查询用户主日历
|
||||
- `search` — 搜索日历
|
||||
|
||||
- 列出某资源下的方法:`lark-cli calendar <resource> -h`
|
||||
- 查看方法的cli flag:`lark-cli calendar <resource> <method> -h`
|
||||
- 查看方法API参数:`lark-cli schema calendar.<resource>.<method>`
|
||||
### event.attendees
|
||||
|
||||
`<resource>` 为 `calendars`(日历本身)/ `events`(日程)/ `event.attendees`(参与人)/ `freebusys`(忙闲)。例:`lark-cli schema calendar.events.delete`。
|
||||
- `batch_delete` — 删除日程参与人
|
||||
- `create` — 添加日程参与人
|
||||
- `list` — 获取日程参与人列表
|
||||
|
||||
## 常用其他域命令
|
||||
### events
|
||||
|
||||
```bash
|
||||
# 搜索用户,更多参数详见 lark-contact
|
||||
lark-cli contact +search-user --query <query> --as user
|
||||
- `create` — 创建日程
|
||||
- `delete` — 删除日程
|
||||
- `get` — 获取日程
|
||||
- `instance_view` — 查询日程视图
|
||||
- `patch` — 更新日程
|
||||
- `share_info` — 获取日程分享链接
|
||||
|
||||
# 搜索群聊,更多参数详见 lark-im
|
||||
lark-cli im +chat-search --query <query> --as user
|
||||
```
|
||||
### freebusys
|
||||
|
||||
- `list` — 查询主日历日程忙闲信息
|
||||
|
||||
## 不在本 skill 范围
|
||||
|
||||
- 查询过去的视频会议记录 → [lark-vc](../lark-vc/SKILL.md)
|
||||
- 待办任务管理 → [lark-task](../lark-task/SKILL.md)
|
||||
- 通讯录 → [lark-contact](../lark-contact/SKILL.md)
|
||||
- 即时通讯 → [lark-im](../lark-im/SKILL.md)
|
||||
- 会议室物理设施管理 → 管理员后台
|
||||
|
||||
**注意(强制性):**
|
||||
|
||||
78
skills/lark-calendar/references/lark-calendar-agenda.md
Normal file
78
skills/lark-calendar/references/lark-calendar-agenda.md
Normal file
@@ -0,0 +1,78 @@
|
||||
|
||||
# calendar +agenda
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
查看近期日程安排。只读操作,不修改任何日程。
|
||||
|
||||
需要的scopes: ["calendar:calendar.event:read"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 查看今天日程(默认)
|
||||
lark-cli calendar +agenda
|
||||
|
||||
# 自定义时间范围(ISO 8601)
|
||||
lark-cli calendar +agenda --start "2026-03-10T00:00+08:00" --end "2026-03-17T00:00+08:00"
|
||||
|
||||
# 自定义时间范围(仅日期)
|
||||
lark-cli calendar +agenda --start 2026-03-10 --end 2026-03-17
|
||||
|
||||
# 人类可读格式输出
|
||||
lark-cli calendar +agenda --format pretty
|
||||
|
||||
# 指定日历
|
||||
lark-cli calendar +agenda --calendar-id cal_xxx
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--start <time>` | 否 | 开始时间(ISO 8601 或仅日期,默认当天) |
|
||||
| `--end <time>` | 否 | 结束时间(默认与 `--start` 属于同一天,自动取当天结束时间) |
|
||||
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用主日历) |
|
||||
| `--format` | 否 | 输出格式:json(默认) \| pretty |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 时间格式
|
||||
|
||||
`--start` 和 `--end` 支持以下格式:
|
||||
|
||||
| 格式 | 示例 | 说明 |
|
||||
|------|------|------|
|
||||
| ISO 8601 | `2026-03-10T14:00:00+08:00` | 完整格式 |
|
||||
| 日期+时间 | `2026-03-10 14:00:00` | 自动补全时区 |
|
||||
| 仅日期 | `2026-03-10` | start 取 00:00:00,end 取 23:59:59 |
|
||||
| Unix 时间戳 | `1741564800` | 秒级时间戳 |
|
||||
|
||||
## 输出格式
|
||||
|
||||
**将结果整理为易读的日程表:**
|
||||
|
||||
```
|
||||
## 2026-03-10 周一
|
||||
|
||||
09:00 - 09:30 站会
|
||||
10:00 - 11:00 产品评审
|
||||
14:00 - 15:00 与 Alice 1:1
|
||||
|
||||
## 2026-03-11 周二
|
||||
|
||||
(无日程)
|
||||
```
|
||||
|
||||
**注意:按日期分组,并严格按照开始时间升序(从早到晚的时间线)排序输出。** 显示标题、时长
|
||||
|
||||
## 提示
|
||||
|
||||
- 已取消的日程会自动过滤,无需额外处理。
|
||||
- 如无日程,告知用户"日程清空"。
|
||||
- 大于 40 天的时间范围会自动拆分查询并合并结果。
|
||||
- 查看多个日历:先用 `lark-cli calendar calendars list --page-all` 列出日历列表,再逐个查询。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-calendar](../SKILL.md) -- 日历全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
@@ -1,9 +1,12 @@
|
||||
|
||||
# calendar +create
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
创建日程并按需邀请参会人。
|
||||
|
||||
需要的scopes: ["calendar:calendar.event:create","calendar:calendar.event:update"]
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
@@ -35,10 +38,10 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
|
||||
| `--description <text>` | 否 | 日程详细描述。提供会议议程、活动内容、注意事项或链接等。与 summary 配合使用,仅关注当前日程信息 |
|
||||
| `--attendee-ids <id_list>` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`)。AI 提取时请务必保留对应前缀 |
|
||||
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用主日历) |
|
||||
| `--rrule <rrule>` | 否 | 重复日程的重复性规则,规则设置方式参考rfc5545。示例值:"FREQ=DAILY;INTERVAL=1;UNTIL=<具体日期>" |
|
||||
| `--rrule <rrule>` | 否 | 重复日程的重复性规则,规则设置方式参考rfc5545。**【⚠️注意:系统绝对不支持 COUNT,如需限制重复次数,必须转为 UNTIL】**。示例值:"FREQ=DAILY;INTERVAL=1" |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
> 当用户表达'每周 X'、'每周重复'、'连续 N 周'时,必须使用 rrule 创建重复性日程,而非创建多个独立日程
|
||||
> **⚠️ `rrule` 规则限制:飞书日历系统不支持 `COUNT` 参数。遇到限制重复次数的需求,必须根据开始时间和频率自行推算并转换成 `UNTIL=<具体日期>` 格式。**
|
||||
> 自动设置 `attendee_ability: "can_modify_event"`,参会人可查看彼此并编辑日程。
|
||||
> 自动设置 `free_busy_status: "busy"`,默认日程忙闲状态为忙碌。
|
||||
> 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。
|
||||
@@ -53,16 +56,44 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
|
||||
- 全天日程的开始日期和结束日期必须分别是日程开始的第一天和结束的最后一天。如果只有一天的话,开始日期和结束日期是相同。
|
||||
|
||||
```bash
|
||||
# 第一步:创建日程(含高级参数)
|
||||
## 查看完整参数定义
|
||||
lark-cli schema calendar.events.create
|
||||
## 创建日程
|
||||
lark-cli calendar events create \
|
||||
--params '{"calendar_id":"<CALENDAR_ID>"}' \
|
||||
--data '{
|
||||
"summary": "技术分享:CLI 架构设计",
|
||||
"start_time": { "timestamp": "1741586400" },
|
||||
"end_time": { "timestamp": "1741593600" }
|
||||
}'
|
||||
|
||||
# 第二步:添加参会人(使用第一步返回的 calendar_id 和 event_id)
|
||||
## 查看完整参数定义
|
||||
lark-cli schema calendar.event.attendees.create
|
||||
## 添加参会人
|
||||
lark-cli calendar event.attendees create \
|
||||
--as user \
|
||||
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>"}' \
|
||||
--data '{"attendees": [{"type": "user", "user_id": "ou_xxx"}]}'
|
||||
|
||||
## 添加需要审批的会议室(approval_reason 最大 200 字符)
|
||||
lark-cli calendar event.attendees create \
|
||||
--as user \
|
||||
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>"}' \
|
||||
--data '{"attendees": [{"type": "resource", "room_id": "omm_xxx", "approval_reason": "申请原因"}]}'
|
||||
|
||||
完整 API 命令的关键差异:
|
||||
- 时间参数是 **Unix 秒字符串**(非 ISO 8601)。
|
||||
- 全天日程的开始日期和结束日期必须分别是日程开始的第一天和结束的最后一天;单日全天日程两者相同。
|
||||
- 手动拆成“创建日程 + 添加参会人”两步时,若第二步失败,建议删除刚创建的空日程,避免遗留无参会人的日程。
|
||||
# 可选第三步(推荐):若第二步失败,回滚删除空日程
|
||||
## 查看完整参数定义
|
||||
lark-cli schema calendar.events.delete
|
||||
## 删除空日程
|
||||
lark-cli calendar events delete \
|
||||
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>","need_notification":false}'
|
||||
|
||||
```
|
||||
|
||||
> 完整 API 命令的时间参数是 **Unix 秒字符串**(非 ISO 8601)。
|
||||
> 当你手动拆成两步执行时,建议保留“失败后回滚删除”的第三步,避免遗留空日程。
|
||||
|
||||
## 参会人类型
|
||||
|
||||
@@ -78,5 +109,6 @@ lark-cli calendar event.attendees create \
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-calendar](../SKILL.md) -- skill 入口与路由
|
||||
- [lark-calendar](../SKILL.md) -- 日历全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
- [lark-calendar-suggestion](lark-calendar-suggestion.md) -- 根据非明确时间或一段时间范围,推荐多个可用时间块方案
|
||||
|
||||
124
skills/lark-calendar/references/lark-calendar-freebusy.md
Normal file
124
skills/lark-calendar/references/lark-calendar-freebusy.md
Normal file
@@ -0,0 +1,124 @@
|
||||
|
||||
# calendar +freebusy
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
|
||||
|
||||
查询用户主日历的忙闲信息,返回指定时间范围内的忙碌时段列表和rsvp的状态。
|
||||
|
||||
需要的scopes: ["calendar:calendar.free_busy:read"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 查询当前用户今天的忙闲(默认)
|
||||
lark-cli calendar +freebusy
|
||||
|
||||
# 自定义时间范围(仅日期)
|
||||
lark-cli calendar +freebusy --start 2026-03-11 --end 2026-03-12
|
||||
|
||||
# 自定义时间范围(完整 ISO 8601)
|
||||
lark-cli calendar +freebusy --start "2026-03-11T08:00:00+08:00" --end "2026-03-11T18:00:00+08:00"
|
||||
|
||||
# 查询指定用户的忙闲信息
|
||||
lark-cli calendar +freebusy --start 2026-03-11 --end 2026-03-12 --user-id ou_xxx
|
||||
|
||||
# 人类可读格式输出
|
||||
lark-cli calendar +freebusy --format pretty
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--start <time>` | 否 | 查询开始时间(ISO 8601 或仅日期,默认当天) |
|
||||
| `--end <time>` | 否 | 查询结束时间(默认与 `--start` 属于同一天,自动取当天结束时间) |
|
||||
| `--user-id <open_id>` | 否 | 目标查询用户 ID(`ou_` 前缀)。省略时默认查询当前登录用户,bot 身份调用时必须明确指定 |
|
||||
| `--format` | 否 | 输出格式:json(默认) \| pretty |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 时间格式
|
||||
|
||||
`--start` 和 `--end` 支持以下格式:
|
||||
|
||||
| 格式 | 示例 | 说明 |
|
||||
|------|------|------|
|
||||
| ISO 8601 | `2026-03-11T09:00:00+08:00` | 完整格式 |
|
||||
| 日期+时间 | `2026-03-11 09:00:00` | 自动补全时区 |
|
||||
| 仅日期 | `2026-03-11` | start 取 00:00:00,end 取 23:59:59 |
|
||||
| Unix 时间戳 | `1741564800` | 秒级时间戳 |
|
||||
|
||||
## 输出示例
|
||||
|
||||
### 表格格式
|
||||
|
||||
```
|
||||
start end rsvp_status
|
||||
---------------- ---------------- -----------
|
||||
2026-03-11 10:00 2026-03-11 10:30 接受
|
||||
2026-03-11 14:00 2026-03-11 15:00 待定
|
||||
|
||||
共 2 个忙碌时段
|
||||
```
|
||||
|
||||
### JSON 格式
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"start_time": "2026-03-11T10:00:00+08:00",
|
||||
"end_time": "2026-03-11T10:30:00+08:00",
|
||||
"rsvp_status": "accept"
|
||||
},
|
||||
{
|
||||
"start_time": "2026-03-11T14:00:00+08:00",
|
||||
"end_time": "2026-03-11T15:00:00+08:00",
|
||||
"rsvp_status": "tentative"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 典型场景
|
||||
|
||||
### 1. 查找日程会议空闲时段
|
||||
|
||||
```bash
|
||||
# 查询今天的忙碌时段
|
||||
lark-cli calendar +freebusy
|
||||
|
||||
# 查询工作时间段
|
||||
lark-cli calendar +freebusy \
|
||||
--start "2026-03-11T08:00:00+08:00" \
|
||||
--end "2026-03-11T18:00:00+08:00"
|
||||
```
|
||||
|
||||
### 2. 检查团队成员可用性
|
||||
|
||||
```bash
|
||||
# 查询多个成员,对比找出共同空闲时间
|
||||
lark-cli calendar +freebusy --start 2026-03-12 --user-id ou_member_a
|
||||
lark-cli calendar +freebusy --start 2026-03-12 --user-id ou_member_b
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **只查询主日历** — 此命令只返回用户主日历的忙闲信息,不包括其他订阅日历
|
||||
2. **隐私保护** — 只返回忙碌时段的起止时间,不包含日程标题、描述等详细信息
|
||||
3. **bot 身份** — bot 必须通过 `--user-id` 指定要查询的用户
|
||||
|
||||
## 与其他命令对比
|
||||
|
||||
| 命令 | 用途 | 输出内容 |
|
||||
|------|------|----------|
|
||||
| `calendar +freebusy` | 查询忙闲时段 | 只返回忙碌时段列表(无日程详情) |
|
||||
| `calendar +agenda` | 查看日程安排 | 返回完整日程列表(含标题、描述等) |
|
||||
|
||||
**选择建议**:
|
||||
- **仅需了解是否有空** → 使用 `+freebusy`(更快,隐私保护)
|
||||
- **需要查看日程详情** → 使用 `+agenda`
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-calendar-agenda](lark-calendar-agenda.md) — 查看日程安排
|
||||
- [lark-calendar-create](lark-calendar-create.md) — 创建日程
|
||||
- [lark-calendar-suggestion](lark-calendar-suggestion.md) — 根据非明确时间或一段时间范围,推荐多个可用时间块方案
|
||||
- [lark-calendar](../SKILL.md) — 日历完整 API
|
||||
@@ -1,8 +1,11 @@
|
||||
# calendar +room-find
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
|
||||
|
||||
针对一个或多个时间块查找/搜索可用会议室。会议室是日程的一种资源型参与人,不能脱离日程单独预定。
|
||||
|
||||
需要的 scopes: ["calendar:calendar.free_busy:read"]
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 已知一个或多个待选时间块,需要查找可用会议室
|
||||
@@ -47,7 +50,7 @@ lark-cli calendar +room-find \
|
||||
| `--city <text>` | 否 | 会议室所在城市强约束。**仅当**用户明确说出具体城市(如北京、上海)时才提取,**严禁**根据园区或楼宇名称自行联想或补全。 |
|
||||
| `--building <text>` | 否 | 会议室所在楼宇强约束,承载城市以下、楼层以上的办公区/园区/楼栋描述。|
|
||||
| `--floor <text>` | 否 | 仅用于筛选会议室所在楼层。应先做归一化,再传递规范值;例如 `2楼` / `二楼` / `2F` 统一为 `F2`。注意:此参数只筛选楼层,不可混入区域定位(如“A区”)或具体会议室号。 |
|
||||
| `--room-name <text>` | 否 | 会议室名称约束,支持以**英文逗号**分隔传入多个名称。仅当用户明确提到会议室专名、会议室号或编号区间时使用。 |
|
||||
| `--room-name <text>` | 否 | 会议室名称约束,支持以**英文逗号**分隔传入多个名称。仅当用户明确提到会议室专名或会议室号(如"木星""02")时使用。当用户需要在一组编号会议室中搜索时(如"帮我约 16~20 号的会议室"),应将编号展开为逗号分隔列表,如 `"16,17,18,19,20"`。应优先传递去后缀、去冗余后的规范名,例如 `木星会议室` → `木星`,`会议室 02` / `02会议室` → `02`。 |
|
||||
| `--min-capacity <n>` | 否 | 会议室最小容纳人数。当用户明确参会人数或提出“至少容纳N人”等要求时,提取数字放入此参数,必须为正整数。 |
|
||||
| `--max-capacity <n>` | 否 | 会议室最大容纳人数。用于过滤过大空间,必须为正整数。 |
|
||||
| `--attendee-ids <id_list>` | 否 | 参会对象 ID 列表。支持用户 ID(`ou_` 前缀)和群组 ID(`oc_` 前缀),多个 ID 以逗号分隔。 |
|
||||
@@ -64,7 +67,7 @@ lark-cli calendar +room-find \
|
||||
- 同一语义槽位只保留一个规范值。例如用户说“2楼”,应转换为 `--floor "F2"`;**禁止**同时传 `2楼 F2` 这类重复楼层信息。
|
||||
- 参数归类顺序应为:`city/building/floor` > `floor + room-name` 复合表达 > `room-name`。若短词更像楼层/区域定位(如 `2L`、`2F`),优先落到 `--floor`,不要默认落到 `--room-name`。像 `学清2层` 这种表达,通常拆为 `--building "学清"` 与 `--floor "F2"`。
|
||||
- 对会议室名要做轻量归一化:`木星会议室` 应提取为 `--room-name "木星"`;`会议室 02` / `02会议室` 应提取为 `--room-name "02"`。
|
||||
- 当用户表达"帮我约 XX 到 YY 号之间的会议室"或一次提及多个会议室名称时,应将所有目标名称用英文逗号拼接传入 `--room-name`。例如:
|
||||
- **多会议室名称场景**:当用户表达"帮我约 XX 到 YY 号之间的会议室"或一次提及多个会议室名称时,应将所有目标名称用英文逗号拼接传入 `--room-name`。例如:
|
||||
- "帮我约 16~20 号的会议室" → `--room-name "16,17,18,19,20"`
|
||||
- "查下木星和火星是否有空" → `--room-name "木星,火星"`
|
||||
- "看看 01、02、03 会议室" → `--room-name "01,02,03"`
|
||||
@@ -87,8 +90,9 @@ lark-cli calendar +room-find \
|
||||
```
|
||||
|
||||
> **AI 行为指导:**
|
||||
> - **结构化展示时间块与会议室**:默认按“时间块 -> 会议室候选”的层级结构展示,并直接询问用户意向。
|
||||
> - **结构化展示时间块与会议室**:默认按“时间块 -> 会议室候选”的层级结构展示。**严禁将时间与会议室名称输出在同一行**。以清晰的分行列表呈现可用会议室,并直接询问用户意向。默认原样展示完整 `room_name`;不要擅自缩写、截断、改写,或仅提取楼层及会议室号替代完整名称。
|
||||
> - **`room_name` 必须逐字透传**:展示给用户的会议室名称,必须直接使用 CLI/API 返回的 `room_name` 原值。禁止提取楼层、会议室号、容量、视频能力后重组成新的名称,禁止意译、缩写、去前缀、去后缀,或仅保留"便于阅读"的摘要名。
|
||||
> - **主动识别区间/多名称意图**:当用户提到"帮我约 XX 到 YY 号的会议室""XX~YY 之间的会议室"或一次列出多个会议室名称时,将所有目标名称展开为英文逗号分隔列表,传入 `--room-name`。例如"帮我约 16 到 20 号的会议室"应生成 `--room-name "16,17,18,19,20"`。
|
||||
> - **重复日程要明确阻断原因与自动缩短**:若某候选会议室的 `reserve_until_time` 无法覆盖重复性日程,**必须**向用户明确说明该会议室最长可约至何时。若用户确认继续选用该会议室,你必须**自动将日程的重复规则结束时间缩短**至该 `reserve_until_time`,以防止会议室预约失败。不能直接按原规则继续。
|
||||
> - **正确解释推荐结果**:如果返回结果与用户输入条件不完全字面一致,先说明底层可能返回邻近位置或相近条件的推荐候选,不要直接将其判定为异常。
|
||||
> - **默认减少用户输入成本**:应主动引导用户不必一开始就提供很详细的会议室搜索条件。只要时间块已明确,用户直接表达“想约会议室”即可,先基于当前信息查询候选;只有在用户对结果不满意时,再引导其补充更具体的楼宇、楼层、会议室名或容量条件。
|
||||
@@ -98,7 +102,7 @@ lark-cli calendar +room-find \
|
||||
| 字段名 | 说明 |
|
||||
| :--- | :--- |
|
||||
| `room_id` | 会议室唯一标识,用于后续创建日程时添加为会议室参与人使用。 |
|
||||
| `room_name` | 会议室名称,展示给用户时必须使用原值。 |
|
||||
| `room_name` | 会议室名称,默认原样完整展示给用户,不要自行缩写、截断、改写,也不要用楼层及会议室号摘要替代原值。 |
|
||||
| `capacity` | 会议室最大容纳人数。 |
|
||||
| `reserve_until_time` | 该会议室当前允许被预约到的最晚时间点,用于校验重复性日程是否超期。 |
|
||||
|
||||
@@ -106,4 +110,4 @@ lark-cli calendar +room-find \
|
||||
|
||||
- [lark-calendar-create](lark-calendar-create.md)
|
||||
- [lark-calendar-suggestion](lark-calendar-suggestion.md)
|
||||
- [lark-calendar](../SKILL.md) — skill 入口与路由
|
||||
- [lark-calendar](../SKILL.md) — 日历完整 API
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# calendar +rsvp
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
回复指定的日程,更新当前用户的 RSVP 状态(接受、拒绝或待定)。
|
||||
|
||||
需要的scopes: ["calendar:calendar.event:reply"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
@@ -35,4 +38,5 @@ lark-cli calendar +rsvp --calendar-id cal_xxx --event-id evt_xxx --rsvp-status a
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-calendar](../SKILL.md) -- skill 入口与路由
|
||||
- [lark-calendar](../SKILL.md) -- 日历全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
# 明确时间分支:room-find + freebusy + 冲突处理
|
||||
|
||||
> 本文档处理**时间已明确**的场景。"明确时间"来源:用户直接表达(如"明天下午3点")、编辑流中已定位日程的原始 start/end、或经用户确认的 suggestion 时间块。
|
||||
|
||||
## 前置条件
|
||||
|
||||
进入此分支前,调度器([schedule-meeting.md](./lark-calendar-schedule-meeting.md))已完成:
|
||||
- 任务类型判定(新建 / 编辑)
|
||||
- 编辑流:目标 event_id 已定位
|
||||
- 新建流:默认值已补全
|
||||
- 时间已判定为**明确**
|
||||
|
||||
## 流程
|
||||
|
||||
### 1. 查询会议室(如需)
|
||||
|
||||
若用户需要会议室,先调用 `+room-find`。详见 [`lark-calendar-room-find.md`](./lark-calendar-room-find.md)。
|
||||
|
||||
```bash
|
||||
lark-cli calendar +room-find \
|
||||
--slot "<start>~<end>" \
|
||||
--attendee-ids "<ids>" \
|
||||
--city "<city>" \
|
||||
--building "<building>" \
|
||||
--floor "<F2>" \
|
||||
--room-name "<room_name>"
|
||||
```
|
||||
|
||||
时间块确定规则:
|
||||
- **编辑流且不改时间,只新增会议室**:`--slot` 必须来自已定位日程的当前 `start/end`
|
||||
- **编辑流且既改时间又加会议室**:`--slot` 必须来自候选新时间,而不是旧时间
|
||||
|
||||
详见 [`lark-calendar-room-find.md`](./lark-calendar-room-find.md)。
|
||||
|
||||
### 2. 查询忙闲
|
||||
|
||||
```bash
|
||||
lark-cli calendar +freebusy --start "<start>" --end "<end>"
|
||||
```
|
||||
|
||||
规则:
|
||||
- 参与人过多(超过 5 人):仅查询**当前用户**及少数核心人员忙闲即可
|
||||
- 参与人含**群组**:无需展开群组成员查询忙闲
|
||||
- 如果用户是从 `+suggestion` 确认了时间块后进入本分支的,**无需再调用 `+freebusy`**
|
||||
|
||||
### 3. 冲突处理
|
||||
|
||||
- **无冲突**:直接让用户选择会议室(如需),进入落地操作
|
||||
- **有冲突**:必须先说明冲突情况,询问用户:
|
||||
- **继续当前时间** → 让用户选择会议室(如需),进入落地操作
|
||||
- **换时间** → 转入 [模糊时间分支](./lark-calendar-schedule-fuzzy-time.md)
|
||||
|
||||
## 落地
|
||||
|
||||
根据任务类型:
|
||||
- 新建 → [`+create`](./lark-calendar-create.md)
|
||||
- 编辑 → [`+update`](./lark-calendar-update.md)
|
||||
|
||||
落地规则详见 [schedule-meeting.md § 落地日程变更](./lark-calendar-schedule-meeting.md#落地日程变更)。
|
||||
@@ -1,88 +0,0 @@
|
||||
# 模糊时间 / 无时间信息分支:suggestion + 批量查询
|
||||
|
||||
> 本文档处理**时间模糊**(如"明天下午""下周找个时间")或**完全无时间信息**的场景。核心动作是调用 `+suggestion` 产出候选时间块,再根据是否需要会议室决定后续步骤。
|
||||
|
||||
## 前置条件
|
||||
|
||||
进入此分支前,调度器([schedule-meeting.md](./lark-calendar-schedule-meeting.md))已完成:
|
||||
- 任务类型判定(新建 / 编辑)
|
||||
- 编辑流:目标 event_id 已定位
|
||||
- 新建流:默认值已补全
|
||||
- 时间已判定为**模糊**或**无时间信息**
|
||||
|
||||
## 流程
|
||||
|
||||
### 1. 调用 suggestion
|
||||
|
||||
详见 [`lark-calendar-suggestion.md`](./lark-calendar-suggestion.md)。
|
||||
|
||||
```bash
|
||||
lark-cli calendar +suggestion \
|
||||
--start "<range_start>" \
|
||||
--end "<range_end>" \
|
||||
--attendee-ids "<ids>" \
|
||||
--duration-minutes <n> \
|
||||
--event-rrule "<rrule>"
|
||||
```
|
||||
|
||||
规则:
|
||||
- 用户完全没有提供时间信息时,先默认一个合理区间(如"今天剩余时间"或"近两天")再调用
|
||||
- 编辑流中,若用户说"改到明天下午""下周找个时间再约",基于用户期望的**新时间范围**调用,不要沿用旧时间
|
||||
- **不要在用户完全没给时间时反问"你想约什么时候"** — 先补合理区间再进入 suggestion
|
||||
|
||||
### 2. 分支处理
|
||||
|
||||
#### 不需要会议室
|
||||
|
||||
获取多个推荐时间块后,直接向用户展示候选时间,用户确认后进入落地操作。
|
||||
|
||||
#### 需要会议室
|
||||
|
||||
获取候选时间块后,**不要急于让用户只选时间**。先将这些时间块一次性交给 `+room-find` 批量查询可用会议室,然后将【候选时间】与【对应的可用会议室列表】结构化展示,让用户一次性完成选择。
|
||||
|
||||
> **注意**:即使用户最初只说"查会议室"且未带时间,也必须强制走 suggestion → room-find 路径。
|
||||
|
||||
详见 [`lark-calendar-room-find.md`](./lark-calendar-room-find.md)。
|
||||
|
||||
### 3. 用户确认后
|
||||
|
||||
- 用户选中 `+suggestion` 返回的时间块后,**无需再次调用 `+freebusy`**,直接进入落地操作
|
||||
- **BLOCKING REQUIREMENT**:必须先向用户展示选项并等待确认,禁止在未获用户确认时直接创建/更新日程
|
||||
|
||||
## 模糊语义消解与长期记忆
|
||||
|
||||
针对存在歧义的时间场景,严禁主观臆断。典型例子:
|
||||
- "上班后" / "下班前"
|
||||
- 未明确上下午的 12 小时制时间
|
||||
|
||||
处理规则:
|
||||
- 主动澄清真实意图,不自行猜测
|
||||
- 用户澄清后,将个性化定义沉淀为长期偏好
|
||||
|
||||
## 用户展示格式
|
||||
|
||||
向用户展示多个时间块及对应会议室时,**必须结构化分行排版**,严禁将时间与会议室放在同一行:
|
||||
|
||||
```text
|
||||
## 2026-03-27 周五
|
||||
|
||||
[选项 1] 14:00 - 15:00(参会人均空闲)
|
||||
可用会议室:
|
||||
1. 学清嘉创大厦B座-F2-02🎦(7人)
|
||||
2. 学清嘉创大厦B座-F2-05🎦(10人)
|
||||
|
||||
[选项 2] 16:00 - 17:00(参会人均空闲)
|
||||
可用会议室:
|
||||
1. 学清嘉创大厦B座-F3-01🎦(6人)
|
||||
2. 学清嘉创大厦B座-F3-06🎦(8人)
|
||||
|
||||
💡 请回复您倾向的选项编号以及对应的会议室序号,我来为您完成预定。
|
||||
```
|
||||
|
||||
## 落地
|
||||
|
||||
根据任务类型:
|
||||
- 新建 → [`+create`](./lark-calendar-create.md)
|
||||
- 编辑 → [`+update`](./lark-calendar-update.md)
|
||||
|
||||
落地规则详见 [schedule-meeting.md § 落地日程变更](./lark-calendar-schedule-meeting.md#落地日程变更)。
|
||||
@@ -1,95 +1,206 @@
|
||||
# 预约/改约日程或会议、查询/搜索可用会议室的工作流
|
||||
|
||||
## 执行摘要
|
||||
## CRITICAL 执行摘要(先按这个骨架执行,再看下方细则)
|
||||
|
||||
- **第一步永远是判断任务类型:新建日程,还是编辑已有日程。**
|
||||
- **编辑已有日程时,必须先定位目标日程或实例的 `event_id`。**
|
||||
- **默认做智能助理,不做表单填写机。** 能根据上下文补全的默认值就直接补全,仅在必须决策的冲突或无法唯一确定的场景下才发起询问。
|
||||
- **新建流先补默认值,编辑流先继承已定位日程信息。**
|
||||
- **明确时间** → 进入 [明确时间分支](./lark-calendar-schedule-clear-time.md)
|
||||
- **模糊时间或无时间信息** → 进入 [模糊时间分支](./lark-calendar-schedule-fuzzy-time.md)
|
||||
- **BLOCKING REQUIREMENT**: 面临时间方案或会议室方案的选择时,必须先向用户展示选项并等待确认,禁止未经确认直接创建/更新日程。
|
||||
- **必须按顺序执行。** 不要跳过"任务类型判定""目标日程定位(编辑流)""补默认值/继承基线信息""判断时间明确性"这些前置步骤。
|
||||
- **第一步永远是判断任务类型:新建日程,还是编辑已有日程。** 不要把“预约/查会议室”默认等同于“新建”。
|
||||
- **编辑已有日程时,必须先定位目标日程或实例的 `event_id`。** 用户一旦给出了既有日程锚点(标题、时间段、`这个日程`、`这场会`)并表达修改动作(加人、删人、改时间、换会议室等),默认走编辑流。
|
||||
- **默认做智能助理,不做表单填写机。** 能根据上下文补全的默认值就直接补全,避免把用户带入表单式问答。
|
||||
- **新建流先补默认值,编辑流先继承已定位日程信息。** 默认值包括标题、参会人、时长,以及在“完全无时间信息”时的默认时间范围;编辑流则优先复用已定位日程的标题、时间、已有参与人和会议室信息作为基线。
|
||||
- **只有三类场景才主动追问用户**:存在时间冲突、搜索结果无法唯一确定、时间语义本身有歧义。
|
||||
- **编辑流的时间基准必须明确。** 如果编辑时不改时间,则后续会议室搜索必须基于已定位日程的原始起止时间;如果既改时间又加会议室,必须先确定最终时间,再基于该时间搜索会议室。
|
||||
- **编辑流中“新增会议室”默认是增量语义。** 如果用户说的是“加会议室/再加一个会议室”,最终 `+update` 只做 `add`,默认保留已有会议室;只有在用户明确说“更换会议室/移除会议室”时,才执行旧会议室删除。
|
||||
- **明确时间**:若需要会议室,先 `+room-find`;再 `+freebusy` 判断参会人忙闲;有冲突时先说明冲突,再让用户决定继续当前时间还是改走 `+suggestion`。
|
||||
- **模糊时间或无时间信息**:先 `+suggestion` 产出候选时间块;若需要会议室,再把这些时间块批量交给 `+room-find`,将“候选时间 + 对应可用会议室”一次性展示给用户选择。
|
||||
- **BLOCKING REQUIREMENT: 只要面临时间方案(模糊时间/无时间)或会议室方案(需要会议室)的选择,必须先向用户展示选项并等待用户明确确认,绝对禁止在未获用户确认的情况下直接执行创建新日程或更新既有日程。**
|
||||
- **用户选中了 `+suggestion` 返回的候选时间块后,不要再次调用 `+freebusy`。** 用户确认后直接进入最终落地操作:创建新日程,或更新既有日程。
|
||||
- **当用户说“查会议室”“找会议室”“搜可用会议室”时,默认意图是查会议室可用性,不是检索会议室资源名录。**
|
||||
- **必须按顺序执行。** 不要跳过“任务类型判定”“目标日程定位(编辑流)”“补默认值/继承基线信息”“判断时间明确性”这些前置步骤。
|
||||
|
||||
> **💡 核心原则:做智能助理,充分利用默认值规则(如默认标题、时长、参与人等)自动补全信息。极力避免像“表单填写机”一样频繁打断并反问用户,仅在必须决策的冲突或无法唯一确定的场景下才发起询问。**
|
||||
|
||||
## 严禁行为
|
||||
|
||||
- **严禁在未读取对应子命令文档前直接调用命令。**
|
||||
- **严禁在尚未判断"新建"还是"编辑"之前,就直接进入创建日程或查会议室动作。**
|
||||
- **严禁把带有既有日程锚点 + 修改动词的请求当成新建日程。**
|
||||
- **严禁在编辑已有日程时跳过目标定位步骤。** 未拿到唯一 `event_id` 前,不得调用 `+update`。
|
||||
- **严禁在面临时间/会议室方案选择时,未经用户确认就擅自创建/更新日程。**
|
||||
- **严禁在未读取对应子命令文档(如 `lark-calendar-room-find.md`、`lark-calendar-suggestion.md`)的情况下直接调用命令!** 必须先阅读文档掌握最新参数要求与规范。
|
||||
- **严禁在尚未判断“新建”还是“编辑”之前,就直接进入创建日程或查会议室动作。**
|
||||
- **严禁把“给明天上午的‘产品发布会’加人/加群/加会议室”这类带有既有日程锚点 + 修改动词的请求,当成新建日程。** 这类请求必须先定位目标日程。
|
||||
- **严禁在编辑已有日程时跳过目标定位步骤。** 未拿到唯一的 `event_id` 前,不得调用 `+update`、也不得基于猜测时间去查会议室。
|
||||
- **严禁在用户仅要求“查会议室”但未提供明确时间时,直接调用 `+room-find`!** 必须先默认一个合理时间范围,调用 `+suggestion` 拿到候选时间块,再将时间块传给 `+room-find`。
|
||||
- **不要在用户完全没给时间时,直接反问“你想约什么时候”。** 先补一个合理时间范围,再进入 `+suggestion`。
|
||||
- **不要在“需要会议室 + 时间模糊”的场景下,先让用户只选时间。** 应先批量查出每个候选时间对应的可用会议室,再让用户一次性完成选择。
|
||||
- **不要在用户已经选中 `+suggestion` 候选时间后,再重复调用 `+freebusy`。**
|
||||
- **不要在用户未明确说出城市时,仅凭园区/办公室名自动补城市。**
|
||||
- **严禁在面临时间方案或会议室方案的选择时(模糊时间、无时间或需要会议室),未经用户确认就擅自创建新日程或更新既有日程。**
|
||||
|
||||
## 适用场景
|
||||
|
||||
- "帮我约个会" / "下周找时间和 XX 开会"
|
||||
- "帮我订/找/搜索一个可用会议室"
|
||||
- "明天下午3点约个日程"
|
||||
- "把明天上午的日程加上 小明"
|
||||
- "给下周一的周会换个会议室"
|
||||
- "把这个日程改到明天下午,并加上学清 F201"
|
||||
- “帮我约个会”
|
||||
- “下周找时间和 XX 开会”
|
||||
- “帮我订个会议室”
|
||||
- “帮我找/搜索一个可用的会议室”
|
||||
- “帮我推荐一个我以前常用的会议室”
|
||||
- “查询明天下午可用的会议室”
|
||||
- “明天下午3点约个日程/日历”
|
||||
- “把明天上午的日程‘产品发布会’加上 小明
|
||||
- “给下周一的周会换个会议室”
|
||||
- “把这个日程改到明天下午,并加上学清 F201”
|
||||
|
||||
## 核心概念
|
||||
|
||||
- **会议室是日程的一种参与人(attendee / resource),不能脱离日程单独预定。**
|
||||
- **预定或查找会议室,均需先确定时间块。**
|
||||
- **当用户说"查会议室""找会议室",默认意图是查会议室可用性,不是检索会议室资源名录。**
|
||||
- **会议室是日程的一种参与人(attendee / resource),不能脱离日程单独预定。**
|
||||
- **预定或查找会议室,均需先确定时间块。** 在推荐可用会议室后,应顺势引导用户完成最终的**日程落地**操作:创建新日程,或更新既有日程。
|
||||
|
||||
## CRITICAL 约束
|
||||
|
||||
- **在调用任何具体的 CLI 子命令(如 `+room-find`、`+suggestion`、`+freebusy`、`+create`)前,必须先读取其对应的 Markdown 文档。** 禁止仅凭记忆组装命令参数,以确保符合各命令最新的业务约束和格式规范。
|
||||
- **当用户说“查会议室”“找会议室”“搜可用会议室”等,默认意图是查询会议室可用性,而不是检索会议室资源名录。**
|
||||
- **必须严格按照下方【工作流】的步骤顺序完成任务。特别是单独查会议室时,若无明确时间,强制先走“模糊时间/无时间信息”分支调用 `+suggestion`。**
|
||||
|
||||
## 任务类型判定
|
||||
|
||||
| 类型 | 典型语言信号 | 第一动作 |
|
||||
|------|--------------|----------|
|
||||
| 新建日程 | "约个会""安排会议""新建日程""订个会议室开会" | 补默认值,再进入时间判断 |
|
||||
| 编辑已有日程 | "给某日程加人/删人/加会议室""把某日程改到…""换会议室" | 先定位目标 `event_id` |
|
||||
| 新建日程 | “约个会”“安排一个会议”“新建日程”“帮我订个会议室开会” | 补默认值,再进入时间判断 |
|
||||
| 编辑已有日程 | “给某个日程加人/删人/加群/加会议室”“把某个日程改到…”“给这场会换个会议室” | 先定位目标日程 `event_id`,再进入后续流程 |
|
||||
|
||||
规则:
|
||||
- 只要同时出现**既有日程锚点**(标题、时间段、`这个日程`、`这场会`)和**修改动词**(添加、移除、改到、换),默认判定为编辑。
|
||||
- 对重复性日程的编辑,必须先定位到对应实例的 `event_id`。
|
||||
进一步规则:
|
||||
|
||||
## 编辑流:先定位目标日程
|
||||
- 只要同时出现**既有日程锚点**(标题、时间段、`这个日程`、`这场会`、某次实例)和**修改动词**(添加、移除、调整、改到、换、延后、提前),默认判定为**编辑已有日程**。
|
||||
- 对重复性日程的编辑,必须先定位到对应实例的 `event_id`,不能直接拿原重复日程的 `event_id` 做更新。
|
||||
|
||||
## 工作流
|
||||
|
||||
### 1. 编辑已有日程:先定位目标日程
|
||||
|
||||
一旦判定为编辑流,必须先定位目标日程;没有 `event_id` 就不能继续后续修改动作。
|
||||
|
||||
定位规则:
|
||||
- 优先利用用户给出的标题、日期、时间范围等锚点,通过 `+agenda`、`+search-event` 或实例视图缩小范围
|
||||
- 命中多个候选日程时,必须向用户展示候选项并要求确认
|
||||
- 重复性日程必须继续定位到该次实例的 `event_id`
|
||||
|
||||
编辑流分支路由:
|
||||
- 优先利用用户给出的标题、日期、时间范围、`这个日程/这场会` 等锚点,通过 `+agenda`、`+search-event` 或实例视图缩小范围。
|
||||
- 如果命中多个候选日程,必须向用户展示候选项并要求确认,禁止自行猜测。
|
||||
- 如果是重复性日程的某一次实例,必须继续定位到该次实例的 `event_id`。
|
||||
|
||||
| 编辑子场景 | 下一步 |
|
||||
|-----------|--------|
|
||||
| 仅增删普通参会人/群组,不改时间,不涉及会议室 | 直接 `+update`(详见 [lark-calendar-update.md](./lark-calendar-update.md)) |
|
||||
| 新增会议室,不改时间 | 基于已定位日程 start/end → [明确时间分支](./lark-calendar-schedule-clear-time.md) |
|
||||
| 只改时间,不涉及会议室 | 判断时间明确性 → 对应分支 |
|
||||
| 既改时间,又新增/更换会议室 | 先确定最终时间 → 再查会议室 → 落地 |
|
||||
编辑流分支规则:
|
||||
|
||||
## 新建日程:智能推断默认值
|
||||
- **仅增删普通参会人/群组,不改时间,也不涉及会议室**:定位完成后可直接进入最终 `+update`。
|
||||
- **新增会议室,但不改时间**:必须基于已定位日程的当前 `start/end` 作为时间块执行 `+room-find`,不能因为用户没重复说时间就退回“无时间信息”。
|
||||
- **既改时间,又新增会议室**:必须先处理时间,拿到最终候选时间块后,再基于该时间执行 `+room-find`;最终只增量添加新会议室,不自动删除已有会议室。
|
||||
- **既改时间,又更换会议室**:必须先处理时间,拿到最终候选时间块后,再基于该时间执行 `+room-find`;只有在用户明确表达“更换”时,最终才执行“移除旧会议室 + 添加新会议室”。
|
||||
- **只改时间,不涉及会议室**:沿用下方时间工作流,但最终落地必须是 `+update`,不是 `+create`。
|
||||
|
||||
- **标题**:根据上下文自动生成;如无法推断,默认"会议"
|
||||
- **参会人**:如未指定,默认仅用户自己
|
||||
- **时长**:基于上下文推断;默认 30 分钟
|
||||
- **无时间信息**:默认推断合理区间(如"今天"或"近两天"),进入时间推荐流程,禁止询问用户
|
||||
### 2. 新建日程:智能推断默认值
|
||||
以下信息智能推断,减少频繁询问用户:
|
||||
|
||||
搜索参与人出现多个结果无法唯一确定时,必须询问用户并记录长期记忆。
|
||||
- **标题**:根据上下文自动生成,例如“沟通对齐”“需求讨论”;如无法推断,默认为“会议”
|
||||
- **参会人**:如未明确指定其他人,默认参会人仅为**用户自己**
|
||||
- **时长**:基于会议类型和上下文动态推断;如无法推断,默认为 30 分钟
|
||||
- **无任何时间信息**:默认推断一个合理区间(如“今天”或“近两天”),并进入时间推荐流程,禁止询问用户
|
||||
|
||||
## 判断时间是否明确
|
||||
当搜索特定参与人(人、群)出现多个结果无法唯一确定时,必须询问用户进行选择确认,并将该偏好记录为长期记忆,以便后续自动识别。
|
||||
|
||||
### 3. 判断时间是否明确
|
||||
|
||||
这一步判断的是**最终要落地的目标时间**,不是只看用户原句里有没有重复说时间。
|
||||
|
||||
时间基准规则:
|
||||
- **新建流**:使用用户给出的时间,或默认补全出的时间范围
|
||||
- **编辑流且不改时间**:已定位日程的当前 `start/end` 就是明确时间
|
||||
- **编辑流且改时间**:用户想改到的新时间;若表达模糊,进入模糊时间分支
|
||||
**注意**: 在执行修改日程/会议时间的任务时,必须先获取原日程的持续时长。如果用户只提供了新的开始时间,你必须根据原时长自动计算出新的结束时间,严格保持原时长不变,禁止擅自改变原日程的时长。
|
||||
|
||||
## 分支路由
|
||||
- **新建流**:使用用户给出的时间,或默认补全出的时间范围作为时间基准。
|
||||
- **编辑流且不改时间**:已定位日程的当前 `start/end` 就是时间基准。后续如需查会议室,直接使用这个明确时间块。
|
||||
- **编辑流且改时间**:用户想改到的新时间才是时间基准;若表达模糊,则进入 `+suggestion`。
|
||||
|
||||
| 判定结果 | 下一步读取 |
|
||||
|----------|-----------|
|
||||
| 明确时间 | [schedule-clear-time.md](./lark-calendar-schedule-clear-time.md) |
|
||||
| 模糊时间 / 无时间信息 | [schedule-fuzzy-time.md](./lark-calendar-schedule-fuzzy-time.md) |
|
||||
分两类处理:
|
||||
|
||||
## 落地日程变更
|
||||
- **明确时间**:如“明天下午3点”
|
||||
- **模糊时间**:如“明天下午”“下周找个时间”
|
||||
|
||||
### 4. 明确时间
|
||||
|
||||
明确时间时,需先判断是否需要会议室,如果需要,提前查询会议室;然后判断是否有时间冲突。这里的“明确时间”既可以来自用户直接表达,也可以来自已定位日程的原始时间。
|
||||
详见 [`+room-find`](./lark-calendar-room-find.md) 与 [`+freebusy`](./lark-calendar-freebusy.md)。
|
||||
|
||||
```bash
|
||||
# 1. 如果需要会议室,提前查询会议室
|
||||
lark-cli calendar +room-find \
|
||||
--slot "<start>~<end>" \
|
||||
--attendee-ids "<ids>" \
|
||||
--city "<city>" \
|
||||
--building "<building>" \
|
||||
--floor "<F2>" \
|
||||
--room-name "<room_name>"
|
||||
|
||||
# 2. 查询当前用户及其他参会人忙闲
|
||||
# (如果有多名参会人,需分别调用查询:--user-id "<ou_xxx>")
|
||||
lark-cli calendar +freebusy --start "<start>" --end "<end>"
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- **参会人过多或包含群组时的处理**:
|
||||
- 如果参与人过多(例如超过 5 人),为避免高耗时,仅需查询**当前用户(自己)**及少数核心人员的忙闲状态即可。
|
||||
- 如果参与人中包含**群组**,无需展开群组成员查询其忙闲状态。
|
||||
- **编辑已有日程且不改时间,只新增会议室时**:这里的 `--slot` 必须来自已定位日程的当前 `start/end`。
|
||||
- **编辑已有日程且既改时间又加会议室时**:这里的 `--slot` 必须来自候选新时间,而不是旧时间;如果用户是“新增会议室”,后续落地只做添加,不删除旧会议室。
|
||||
- **如果没有冲突**:直接让用户选择会议室(如需),然后进入最终落地操作:创建新日程,或更新既有日程
|
||||
- **如果有冲突**:必须先说明冲突情况,询问用户继续选择这个时间还是换个时间
|
||||
- **如果说换个时间**:放弃当前时间,转入【模糊时间】流程,调用 `+suggestion` 推荐多个可用时间块
|
||||
- **如果继续选择这个时间**:直接让用户选择会议室(如需),然后进入最终落地操作:创建新日程,或更新既有日程
|
||||
- 位置信息要优先拆到结构化字段:用户明确说了城市才提取 `--city`;`--building` 不要再重复携带城市前缀。
|
||||
- 参数归类顺序应为:`city/building/floor` > `floor + room-name` 复合表达 > `room-name`。像 `2L`、`2F` 这类更像楼层或区域定位的短词,优先视为 `--floor`,不要默认当作 `--room-name`。像 `学清2层` 这种表达,通常拆为 `--building "学清"` 与 `--floor "F2"`。
|
||||
- 会议室名要做轻量归一化:`木星会议室` -> `--room-name "木星"`;`会议室 02` / `02会议室` -> `--room-name "02"`。
|
||||
- 对 `F3-05` / `F5-07` / `3楼-08` 这类复合表达,若能稳定识别楼层与会议室号,应优先提取为 `--floor + --room-name`,不要把整段直接退化成 `--room-name`。
|
||||
|
||||
### 5. 模糊时间或无时间信息
|
||||
|
||||
先调用:
|
||||
详见 [`+suggestion`](./lark-calendar-suggestion.md);若需要会议室,再结合 [`+room-find`](./lark-calendar-room-find.md)。
|
||||
|
||||
```bash
|
||||
lark-cli calendar +suggestion \
|
||||
--start "<range_start>" \
|
||||
--end "<range_end>" \
|
||||
--attendee-ids "<ids>" \
|
||||
--duration-minutes <n> \
|
||||
--event-rrule "<rrule>"
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 若用户完全没有提供时间信息,应先默认一个合理区间后再调用 `+suggestion`
|
||||
- 编辑流中,若用户表达的是“改到明天下午”“下周找个时间再约”这类模糊新时间,则基于用户期望的新时间范围调用 `+suggestion`;不要继续沿用旧时间。
|
||||
- **不需要会议室**:获取多个推荐时间块后,直接向用户展示候选时间,用户确认后进入最终落地操作:创建新日程,或更新既有日程。
|
||||
- **需要会议室**:获取多个候选时间块后,**不要急于让用户选时间**。先将这些时间块一次性交给 `calendar +room-find` 批量查询可用会议室,然后将【候选时间】与【对应的可用会议室列表】结构化分行展示,让用户一次性完成选择。(**注意:即使用户最初只说“查会议室”,且未带时间,也必须强制走到这一步,先 suggestion 再 room-find**)。
|
||||
- 用户一旦选择了 `+suggestion` 返回的时间块,**无需再次调用 `+freebusy`**
|
||||
|
||||
### 6. 模糊语义消解与长期记忆构建
|
||||
|
||||
针对用户专属的时间表达习惯或存在歧义的时间场景,严禁主观臆断。典型例子包括:
|
||||
|
||||
- “上班后”
|
||||
- “下班前”
|
||||
- 未明确上下午的 12 小时制时间表达
|
||||
|
||||
处理规则:
|
||||
|
||||
- 应主动澄清真实意图,而不是自行猜测
|
||||
- 当用户给出澄清后,应将这类个性化定义沉淀为长期偏好,推动后续直接理解类似表达
|
||||
|
||||
### 7. 重复性日程
|
||||
|
||||
若当前会议为重复性日程,调用 `+room-find` 时需携带 `--event-rrule`。
|
||||
|
||||
必须检查返回中的:
|
||||
|
||||
- `reserve_until_time`
|
||||
|
||||
若候选会议室的可预约上限早于重复规则覆盖范围,**不要直接按原规则落地日程**。应:
|
||||
|
||||
- 向用户明确说明该会议室最长可约至何时。
|
||||
- 若用户确认继续选用该会议室,你必须**自动将日程的重复规则结束时间缩短**至该 `reserve_until_time`,以防止会议室预约失败。
|
||||
|
||||
### 8. 落地日程变更
|
||||
|
||||
用户确认后调用:
|
||||
- 新建 → [`+create`](./lark-calendar-create.md)
|
||||
- 编辑 → [`+update`](./lark-calendar-update.md)
|
||||
如果是新建会议,详见 [`+create`](./lark-calendar-create.md)。
|
||||
如果是更新既有日程,详见 [`+update`](./lark-calendar-update.md)。必须先定位目标 `event_id`,再按用户意图用 `+update` 独立执行字段更新、添加参会人/会议室、移除参会人/会议室,或组合这些动作。若用户意图是“新增会议室”,默认仅追加 `room_id`,不移除已有会议室。
|
||||
|
||||
```bash
|
||||
lark-cli calendar +create \
|
||||
@@ -103,20 +214,52 @@ lark-cli calendar +update \
|
||||
--start "<start>" \
|
||||
--end "<end>" \
|
||||
--add-attendee-ids "omm_new_room"
|
||||
|
||||
# 仅当用户明确要求“更换会议室”时,才同时移除旧会议室并添加新会议室
|
||||
lark-cli calendar +update \
|
||||
--event-id "<event_id>" \
|
||||
--remove-attendee-ids "omm_old_room" \
|
||||
--add-attendee-ids "omm_new_room"
|
||||
```
|
||||
|
||||
落地规则:
|
||||
- 编辑流必须始终沿用前面定位得到的目标 `event_id`;禁止在最后一步重新猜测目标日程
|
||||
- 编辑流中"新增会议室"默认仅追加 `room_id`,不移除已有会议室
|
||||
- 仅当用户明确说"更换会议室"时,才同时 `--remove-attendee-ids` 旧 + `--add-attendee-ids` 新
|
||||
- 需要会议室时,将选中的 `room_id` 写入参与人列表
|
||||
规则:
|
||||
- 新建日程时,可使用 `+create`
|
||||
- 更新既有日程时,优先使用 `+update`。改时间/标题/描述、添加参会人/会议室、移除参会人/会议室可以分别独立执行;
|
||||
- 编辑流必须始终沿用前面定位得到的目标 `event_id`;禁止在最后一步重新按标题猜测一次目标日程。
|
||||
- 编辑流中如果只是新增群组或普通参会人,不涉及时间和会议室,可直接 `+update --add-attendee-ids ...`。
|
||||
- 编辑流中如果是“新增会议室但不改时间”,必须先基于目标日程原始时间查到可用会议室,再 `+update --add-attendee-ids "<room_id>"`;默认保留已有会议室。
|
||||
- 编辑流中如果是“既改时间又新增会议室”,顺序必须是:先确定最终时间,再查会议室,最后一次性 `+update` 时间与新增会议室;默认保留已有会议室。
|
||||
- 编辑流中如果是“既改时间又更换会议室”,顺序必须是:先确定最终时间,再查会议室,最后一次性 `+update` 时间、移除旧会议室并添加新会议室。
|
||||
- 需要会议室时,将选中的 `room_id` 写入最终落地请求的参与人列表
|
||||
- 展示会议室候选时,必须保留 CLI/API 返回的完整 `room_name` 原值;允许附加“推断说明”,但禁止用摘要名、楼层及会议室号、容量/视频标签重组后的名称替换原值
|
||||
|
||||
## 用户展示建议
|
||||
|
||||
当向用户展示多个时间块及对应的多个会议室时,**必须使用结构化清晰的格式排版**。**严禁将时间与会议室名称放在同一行展示**,必须分行并使用编号列表呈现可用会议室,严禁将所有信息揉成一团纯文本堆叠。
|
||||
|
||||
**推荐展示格式参考:**
|
||||
|
||||
```text
|
||||
## 2026-03-27 周五
|
||||
|
||||
[选项 1] 14:00 - 15:00(参会人均空闲)
|
||||
可用会议室:
|
||||
1. 学清嘉创大厦B座-F2-02🎦(7人)
|
||||
2. 学清嘉创大厦B座-F2-05🎦(10人)
|
||||
|
||||
[选项 2] 16:00 - 17:00(参会人均空闲)
|
||||
可用会议室:
|
||||
1. 学清嘉创大厦B座-F3-01🎦(6人)
|
||||
2. 学清嘉创大厦B座-F3-06🎦(8人)
|
||||
|
||||
💡 请回复您倾向的选项编号以及对应的会议室序号,我来为您完成预定。
|
||||
```
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-calendar-schedule-clear-time.md](./lark-calendar-schedule-clear-time.md)
|
||||
- [lark-calendar-schedule-fuzzy-time.md](./lark-calendar-schedule-fuzzy-time.md)
|
||||
- [lark-calendar-room-find.md](./lark-calendar-room-find.md)
|
||||
- [lark-calendar-freebusy.md](./lark-calendar-freebusy.md)
|
||||
- [lark-calendar-suggestion.md](./lark-calendar-suggestion.md)
|
||||
- [lark-calendar-create.md](./lark-calendar-create.md)
|
||||
- [lark-calendar-update.md](./lark-calendar-update.md)
|
||||
- [SKILL.md](../SKILL.md)
|
||||
- [lark-shared](../../lark-shared/SKILL.md)
|
||||
- [lark-calendar](../SKILL.md)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
|
||||
# calendar +search-event
|
||||
|
||||
按关键词、时间范围和参会人搜索日历日程。只读。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 按关键词
|
||||
lark-cli calendar +search-event --query "周会"
|
||||
|
||||
# 按时间范围(ISO 8601 或 YYYY-MM-DD)
|
||||
lark-cli calendar +search-event --start "2026-04-20T00:00:00+08:00" --end "2026-04-27T23:59:59+08:00"
|
||||
|
||||
# 按参会人(自动识别 ou_ 用户 / oc_ 群聊 / omm_ 会议室前缀)
|
||||
lark-cli calendar +search-event --attendee-ids "ou_user1,oc_chat1,omm_room1"
|
||||
|
||||
# 组合
|
||||
lark-cli calendar +search-event --query "周会" --start 2026-04-20 --end 2026-04-27 --attendee-ids "ou_user1"
|
||||
```
|
||||
|
||||
## 输出字段
|
||||
|
||||
`items` 列表每条返回 `event_id` / `summary` / `start` / `end` / `is_all_day` / `app_link`;外层有 `has_more`、`page_token`。**仅返回基础字段,要拿日程详情用 `calendar events get`。**
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 分页:`has_more=true` 时持续用 `page_token` 翻页直到 false,不要遗漏;`page-size` 最大 30。
|
||||
- 已结束的会议优先用 `vc +search`——日历不收录"即时会议",只查日程会漏。
|
||||
@@ -1,5 +1,6 @@
|
||||
# calendar +suggestion
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
|
||||
|
||||
根据非明确时间或一段时间范围,推荐多个可用时间块方案。帮助用户解决协调时间的难题。
|
||||
|
||||
@@ -7,6 +8,8 @@
|
||||
- ✅ **当用户需求涉及寻找时间块,且时间未完全确定**(如`今天`、`近三天`、`本周`、`下午`, `无时间描述`)时,调用此工具来获取推荐时间块给用户选择(包括但不限于预约日程)。
|
||||
- ❌ **当用户已经明确了具体的时间点**(如`今天下午3点`),则**不需要**调用此工具
|
||||
|
||||
需要的scopes: ["calendar:calendar.free_busy:read"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
@@ -118,4 +121,5 @@ lark-cli calendar +suggestion \
|
||||
## 参考
|
||||
|
||||
- [lark-calendar-create](lark-calendar-create.md) — 创建日程
|
||||
- [lark-calendar](../SKILL.md) — skill 入口与路由
|
||||
- [lark-calendar-freebusy](lark-calendar-freebusy.md) — 查询忙闲时段和rsvp状态
|
||||
- [lark-calendar](../SKILL.md) — 日历完整 API
|
||||
@@ -1,10 +1,13 @@
|
||||
# calendar +update
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
更新既有日程字段,或独立增量添加/移除参会人和会议室。
|
||||
|
||||
`+update` 支持三类互相独立的动作:更新日程字段、添加参会人/会议室、移除参会人/会议室。它们可以单独执行,也可以在同一次命令中组合执行。
|
||||
|
||||
需要的 scopes: ["calendar:calendar.event:update"]
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
@@ -63,8 +66,8 @@ lark-cli calendar +update \
|
||||
- 如需替换某个参与人、群组或会议室,使用 `--remove-attendee-ids <旧ID>` + `--add-attendee-ids <新ID>`。
|
||||
- 会议室是 resource attendee,必须使用 `omm_` ID 添加到参会人列表,不能脱离日程单独预定。
|
||||
- 更新重复性日程时,必须先确定操作范围(仅此次/全部/此次及后续),然后按 [重复性日程操作规范](lark-calendar-recurring.md) 执行。
|
||||
- 如果需要验证更新结果,等待至少 2 秒后再查询,避免同步延迟导致读到旧数据。
|
||||
- 当同一次命令组合多个动作时,执行顺序为“日程字段 -> 移除参会人 -> 添加参会人”。若中途失败,不会自动回滚已成功步骤;错误信息会说明已完成的步骤。
|
||||
**⚠️ 高风险操作**: 修改时间时必须先读取原日程时长并计算新 end。如果 end 计算错误,会导致日程时长变化,用户会直接感知,禁止擅自改变原日程的时长。
|
||||
|
||||
## 高级用法(完整 API 命令)
|
||||
|
||||
@@ -95,6 +98,8 @@ lark-cli calendar +update \
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-calendar](../SKILL.md) -- skill 入口与路由
|
||||
- [lark-calendar](../SKILL.md) -- 日历全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
- [lark-calendar-schedule-meeting](lark-calendar-schedule-meeting.md) -- 预约/改约会议与会议室工作流
|
||||
- [lark-calendar-room-find](lark-calendar-room-find.md) -- 查找可用会议室
|
||||
- [lark-calendar-freebusy](lark-calendar-freebusy.md) -- 查询忙闲
|
||||
|
||||
@@ -15,10 +15,9 @@
|
||||
| `summary.skipped` | 因 `--if-exists=skip` 或 `--if-exists=smart` 命中“无需传输”而跳过的文件数 |
|
||||
| `summary.failed` | 上传 / 覆盖 / 建目录 / 删除失败的条目数;**只要不为 0,命令就以非零状态退出**(结构化 `items[]` 仍在 stdout 上) |
|
||||
| `summary.deleted_remote` | 启用 `--delete-remote --yes` 时删除的云端文件数 |
|
||||
| `summary.aborted` | 命中终止性错误并停止后续批处理时为 `true` |
|
||||
| `items[]` | 每个条目的明细(`rel_path` / `file_token` / `action` / 覆盖时的 `version` / `size_bytes` / 失败时的 `error` / `hint` / `phase` / `error_class` / `code` / `subtype` / `retryable`) |
|
||||
| `items[]` | 每个条目的明细(`rel_path` / `file_token` / `action` / 覆盖时的 `version` / `size_bytes` / 失败时的 `error`) |
|
||||
|
||||
`items[].action` 取值:`uploaded` / `overwritten` / `skipped` / `folder_created` / `deleted_remote` / `already_deleted` / `failed` / `delete_failed`。
|
||||
`items[].action` 取值:`uploaded` / `overwritten` / `skipped` / `folder_created` / `deleted_remote` / `failed` / `delete_failed`。
|
||||
|
||||
> 本地目录(包括空目录)会被镜像到 Drive;新建的子目录会以 `action: "folder_created"` 出现在 `items[]` 里,但**不计入** `summary.uploaded`(该字段只数文件)。已存在的远端目录复用其 token,不会重复 `create_folder`,也不会出现在 `items[]` 里。
|
||||
|
||||
@@ -96,7 +95,6 @@ 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` 的对齐域。
|
||||
@@ -112,46 +110,22 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
"uploaded": 0,
|
||||
"skipped": 0,
|
||||
"failed": 0,
|
||||
"deleted_remote": 0,
|
||||
"aborted": false
|
||||
"deleted_remote": 0
|
||||
},
|
||||
"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": "...", "hint": "...", "phase": "upload", "error_class": "...", "code": 0, "subtype": "...", "retryable": false},
|
||||
{"rel_path": "...", "action": "failed", "size_bytes": 0, "error": "..."},
|
||||
{"rel_path": "...", "file_token": "...", "action": "deleted_remote"},
|
||||
{"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": "...", "file_token": "...", "action": "delete_failed", "error": "..."}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`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` 跳过已对齐的远端文件,但对“远端更旧”的文件仍会进入覆盖路径,因此它减少的是**不必要的重传**,不是把覆盖风险完全拿掉。
|
||||
|
||||
@@ -79,7 +79,7 @@ metadata:
|
||||
|
||||
1. `+triage --from spam@x.com` → 列出 N 条结果
|
||||
2. 展示:"将删除 N 封邮件(发件人 spam@x.com,主题:…),确认?"
|
||||
3. 用户确认后 → `+message-trash --message-ids ... --yes`
|
||||
3. 用户确认后 → `*.batch_trash`
|
||||
|
||||
## 身份选择:优先使用 user 身份
|
||||
|
||||
@@ -96,14 +96,13 @@ metadata:
|
||||
1. **确认身份** — 首次操作邮箱前先调用 `lark-cli mail user_mailboxes profile --params '{"user_mailbox_id":"me"}'` 获取当前用户的真实邮箱地址(`primary_email_address`),不要通过系统用户名猜测。后续判断"发件人是否为用户本人"时以此地址为准。
|
||||
2. **浏览** — `+triage` 查看收件箱摘要,获取 `message_id` / `thread_id`
|
||||
3. **阅读** — `+message` 只读单封邮件;已有多个 `message_id` 时用 `+messages` 批量读取,不要循环调用 `+message`;`+thread` 读整个会话
|
||||
4. **整理** — 标签、已读/未读状态和移动文件夹优先用 `+message-modify`;软删除优先用 `+message-trash`
|
||||
5. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
6. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
7. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送
|
||||
8. **HTML body 预检(可选)** — 复杂 HTML body 提交前可先跑 `+lint-html` 看 lint 会改 / 删什么;写信路径(`+send` / `+draft-create` / `+reply` / `+reply-all` / `+forward` / `+draft-edit` body op)已内置 autofix,普通正文不必先跑。详见 [references/lark-mail-html.md](references/lark-mail-html.md) 中的「写入路径内置 HTML lint」章节
|
||||
9. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
|
||||
10. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
|
||||
11. **已读回执** —
|
||||
4. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
5. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
6. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送
|
||||
7. **HTML body 预检(可选)** — 复杂 HTML body 提交前可先跑 `+lint-html` 看 lint 会改 / 删什么;写信路径(`+send` / `+draft-create` / `+reply` / `+reply-all` / `+forward` / `+draft-edit` body op)已内置 autofix,普通正文不必先跑。详见 [references/lark-mail-html.md](references/lark-mail-html.md) 中的「写入路径内置 HTML lint」章节
|
||||
8. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
|
||||
9. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
|
||||
10. **已读回执** —
|
||||
- **请求回执(写信侧)**:`--request-receipt` 仅在**用户显式要求**时添加,**不要从 subject / body 内容推断意图**。
|
||||
- **响应回执(拉信侧)**:拉信看到 `label_ids` 含 `READ_RECEIPT_REQUEST`(或 `-607`)时,**必须先问用户**是否回执(不要自动回执,涉及隐私)。用户同意 → `+send-receipt` 响应;用户不同意但想消掉提示 → `+decline-receipt` 只清本地标签、不发邮件。
|
||||
|
||||
@@ -120,8 +119,6 @@ metadata:
|
||||
- 查看发送邮件后的投递状态:发送成功后查看邮件投递状态;也覆盖发送拦截。ref: [lark-mail-send-status](references/lark-mail-send-status.md)
|
||||
- 使用邮件模板:区分个人模板和静态 HTML 模板,发信类 shortcut 用 `--template-id` 套用模板。ref: [lark-mail-template](references/lark-mail-template.md)
|
||||
- 撤回已发送邮件:撤回邮件并查询异步撤回状态。ref: [lark-mail-recall](references/lark-mail-recall.md)
|
||||
- 修改邮件标签/已读状态/文件夹:优先使用 `+message-modify`。ref: [`+message-modify`](references/lark-mail-message-modify.md)
|
||||
- 软删除邮件:优先使用 `+message-trash`。ref: [`+message-trash`](references/lark-mail-message-trash.md)
|
||||
- 收信规则:创建、验证、删除自动处理收到邮件的规则。ref: [lark-mail-rules](references/lark-mail-rules.md)
|
||||
- 分享邮件到 IM:分享邮件或会话到群聊、个人会话。ref: [lark-mail-share-to-chat](references/lark-mail-share-to-chat.md)
|
||||
- 发送日程邀请邮件:在邮件中嵌入 `text/calendar` 日程邀请。ref: [lark-mail-calendar-invite](references/lark-mail-calendar-invite.md)
|
||||
@@ -195,7 +192,7 @@ lark-cli mail +messages --message-ids <id1>,<id2>,<id3> --html=false
|
||||
|
||||
## 原生 API 调用规则
|
||||
|
||||
没有 Shortcut 覆盖的操作才使用原生 API。标签、已读状态、移动文件夹优先使用 `+message-modify`;软删除优先使用 `+message-trash`。调用步骤以本节为准;资源和 method 用 `lark-cli mail -h` / `lark-cli mail <resource> -h` 发现,不在入口保留完整资源表。
|
||||
没有 Shortcut 覆盖的操作才使用原生 API。调用步骤以本节为准;资源和 method 用 `lark-cli mail -h` / `lark-cli mail <resource> -h` 发现,不在入口保留完整资源表。
|
||||
|
||||
### Step 1 — 用 `-h` 确定要调用的 API(必须,不可跳过)
|
||||
|
||||
|
||||
@@ -215,7 +215,7 @@ lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox
|
||||
**2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意:
|
||||
|
||||
```bash
|
||||
lark-cli mail +message-modify --message-ids <原邮件ID> --remove-label-ids UNREAD
|
||||
lark-cli mail user_mailbox.messages batch_modify --params '{"user_mailbox_id":"me"}' --data '{"message_ids":["<原邮件ID>"],"remove_label_ids":["UNREAD"]}'
|
||||
```
|
||||
|
||||
## 编辑转发草稿
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
# mail +message-modify
|
||||
|
||||
`mail +message-modify` is the preferred shortcut for changing labels, read-state labels, or folder placement on existing messages.
|
||||
|
||||
Use it instead of raw `user_mailbox.messages batch_modify` when the operation targets concrete `message_id` values from `+triage`, `+message`, or `+messages`.
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
lark-cli mail +message-modify --message-ids <id1>,<id2> --add-label-ids unread
|
||||
lark-cli mail +message-modify --message-ids <id> --remove-label-ids FLAGGED
|
||||
lark-cli mail +message-modify --message-ids <id> --add-folder archive
|
||||
lark-cli mail +message-modify --mailbox shared@example.com --message-ids <id> --add-folder folder_xxx
|
||||
lark-cli mail +message-modify --message-ids <id> --add-label-ids custom_label_id --dry-run
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Required | Notes |
|
||||
| --- | --- | --- |
|
||||
| `--mailbox` | No | Mailbox that owns the messages. Defaults to `me`. |
|
||||
| `--message-ids` | Yes | `string_array`; supports comma-separated values and repeated flags. |
|
||||
| `--add-label-ids` | No | Adds labels. System labels `unread`, `important`, `other`, `flagged` normalize to upper case. |
|
||||
| `--remove-label-ids` | No | Removes labels. Cannot overlap with `--add-label-ids`. |
|
||||
| `--add-folder` | No | Moves to one folder. `inbox`, `sent`, `spam`, `archive`, `archived` normalize to system folder IDs. |
|
||||
|
||||
`TRASH` is intentionally rejected by this shortcut. Use `mail +message-trash --message-ids <id> --yes` for soft deletion.
|
||||
|
||||
## Behavior
|
||||
|
||||
- Message IDs are locally validated, de-duplicated in first-seen order, and sent in batches of 20.
|
||||
- Custom label IDs are checked with `labels.get`; custom folder IDs are checked with `folders.get`.
|
||||
- If no label or folder operation is requested, the command succeeds locally, emits all message IDs as `success_message_ids`, and makes no POST request.
|
||||
- Single batch POST failures mark every message in that batch with the same failure reason; later batches still run.
|
||||
- JSON output is intentionally compact:
|
||||
|
||||
```json
|
||||
{
|
||||
"success_message_ids": ["id1"],
|
||||
"failed_message_ids": [
|
||||
{"message_id": "id2", "reason": "api error"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## When Raw API Is Still Appropriate
|
||||
|
||||
Use raw `mail user_mailbox.messages batch_modify` only when you need a request shape that the shortcut intentionally does not expose, or when reproducing backend/API behavior exactly for diagnostics.
|
||||
@@ -1,41 +0,0 @@
|
||||
# mail +message-trash
|
||||
|
||||
`mail +message-trash` is the preferred shortcut for soft-deleting existing messages.
|
||||
|
||||
Use it after obtaining real `message_id` values from `+triage`, `+message`, or `+messages`, and after the user has confirmed the deletion preview.
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
lark-cli mail +message-trash --message-ids <id1>,<id2> --yes
|
||||
lark-cli mail +message-trash --mailbox shared@example.com --message-ids <id> --yes
|
||||
lark-cli mail +message-trash --message-ids <id1> --message-ids <id2> --dry-run
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Required | Notes |
|
||||
| --- | --- | --- |
|
||||
| `--mailbox` | No | Mailbox that owns the messages. Defaults to `me`. |
|
||||
| `--message-ids` | Yes | `string_array`; supports comma-separated values and repeated flags. |
|
||||
| `--yes` | Yes for execution | Required by the high-risk write confirmation framework. |
|
||||
|
||||
## Behavior
|
||||
|
||||
- Message IDs are locally validated, de-duplicated in first-seen order, and sent in batches of 20.
|
||||
- The shortcut calls `POST /open-apis/mail/v1/user_mailboxes/<mailbox>/messages/batch_trash` sequentially.
|
||||
- Single batch POST failures mark every message in that batch with the same failure reason; later batches still run.
|
||||
- JSON output is intentionally compact:
|
||||
|
||||
```json
|
||||
{
|
||||
"success_message_ids": ["id1"],
|
||||
"failed_message_ids": [
|
||||
{"message_id": "id2", "reason": "api error"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## When Raw API Is Still Appropriate
|
||||
|
||||
Use raw `mail user_mailbox.messages batch_trash` only when reproducing backend/API behavior exactly for diagnostics. For normal soft deletion, prefer this shortcut because it handles validation, batching, compact output, and `--yes` confirmation consistently.
|
||||
@@ -203,7 +203,7 @@ lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox
|
||||
**2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意:
|
||||
|
||||
```bash
|
||||
lark-cli mail +message-modify --message-ids <原邮件ID> --remove-label-ids UNREAD
|
||||
lark-cli mail user_mailbox.messages batch_modify --params '{"user_mailbox_id":"me"}' --data '{"message_ids":["<原邮件ID>"],"remove_label_ids":["UNREAD"]}'
|
||||
```
|
||||
|
||||
## 相关命令
|
||||
|
||||
@@ -218,7 +218,7 @@ lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox
|
||||
**2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意:
|
||||
|
||||
```bash
|
||||
lark-cli mail +message-modify --message-ids <原邮件ID> --remove-label-ids UNREAD
|
||||
lark-cli mail user_mailbox.messages batch_modify --params '{"user_mailbox_id":"me"}' --data '{"message_ids":["<原邮件ID>"],"remove_label_ids":["UNREAD"]}'
|
||||
```
|
||||
|
||||
## 编辑回复草稿
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: lark-markdown
|
||||
version: 1.2.2
|
||||
version: 1.2.1
|
||||
description: "飞书 Markdown:查看、创建、上传、编辑和比较 Markdown 文件。当用户需要创建或编辑 Markdown 文件、读取、修改、局部 patch 或比较差异时使用。不负责将 Markdown 导入为飞书在线文档,也不负责文件搜索、权限、评论、移动、删除等云空间管理操作。"
|
||||
metadata:
|
||||
requires:
|
||||
@@ -25,8 +25,7 @@ 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`、`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` 试错。
|
||||
- `markdown +create` / `+overwrite` 命中 `missing scope`、`permission denied`、`not found`、`version limit` 时,默认停止重试并按报错 hint 处理;只有 `rate limit` 或临时网络错误才做有限重试。
|
||||
|
||||
## 核心边界
|
||||
|
||||
|
||||
@@ -32,21 +32,11 @@ 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 \
|
||||
@@ -58,8 +48,8 @@ lark-cli markdown +create \
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--folder-token` | 否 | 目标 Drive 文件夹 token 或 Drive folder URL;与 `--wiki-token` 互斥;省略时创建到根目录 |
|
||||
| `--wiki-token` | 否 | 目标 wiki 节点 token 或 wiki URL;与 `--folder-token` 互斥;传入后自动映射为 `parent_type=wiki` |
|
||||
| `--folder-token` | 否 | 目标 Drive 文件夹 token;与 `--wiki-token` 互斥;省略时创建到根目录 |
|
||||
| `--wiki-token` | 否 | 目标 wiki 节点 token;与 `--folder-token` 互斥;传入后自动映射为 `parent_type=wiki` |
|
||||
| `--name` | 条件必填 | 文件名,**必须显式带 `.md` 后缀**;使用 `--content` 时必填;使用 `--file` 时可省略,默认取本地文件名 |
|
||||
| `--content` | 条件必填 | Markdown 内容;与 `--file` 互斥;支持直接传字符串、`@file`、`-`(stdin) |
|
||||
| `--file` | 条件必填 | 本地 `.md` 文件路径;与 `--content` 互斥 |
|
||||
@@ -68,8 +58,6 @@ 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
|
||||
@@ -100,14 +88,6 @@ 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,7 @@
|
||||
|
||||
# minutes +download
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
下载妙记的音视频媒体文件到本地,或获取有效期 1 天的下载链接。只读操作。
|
||||
|
||||
@@ -133,3 +134,4 @@ API 限流 5 次/秒,批量下载时需注意控制频率。
|
||||
|
||||
- [lark-minutes](../SKILL.md) — 妙记全部命令
|
||||
- [lark-minutes-detail](lark-minutes-detail.md) — 妙记详情与 AI 产物查询
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# minutes +search
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
搜索妙记列表,支持关键词、所有者、参与者以及时间范围等多条件过滤。所有者与参与者都支持传入多个 open\_id,也支持传入 `me` 表示当前用户。只读操作,不修改任何妙记数据。
|
||||
|
||||
@@ -198,5 +199,6 @@ lark-cli minutes +detail --minute-tokens <minute_token> --summary
|
||||
|
||||
- [lark-minutes](../SKILL.md) -- 妙记相关命令
|
||||
- [lark-minutes-detail](lark-minutes-detail.md) -- 基于 `minute_token` 获取逐字稿、总结、待办、章节等产物
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
- [lark-vc](../../lark-vc/SKILL.md) -- 视频会议全部命令
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# minutes +speaker-replace
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
替换妙记逐字稿中的说话人身份:把妙记逐字稿里"原说话人"对应的所有发言段,重新归属到"新说话人"。常用于解决妙记自动识别错说话人,或需要把外部/非飞书说话人改绑到正确飞书用户的场景。
|
||||
|
||||
@@ -105,3 +106,4 @@ Agent 必须先 `lark-cli api GET .../speakerlist`,再 `+speaker-replace`;`-
|
||||
## 参考
|
||||
|
||||
- [lark-minutes](../SKILL.md) -- 妙记相关功能说明
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# minutes +summary
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
替换妙记的 AI 总结内容。写操作,会覆盖当前总结。
|
||||
|
||||
@@ -118,3 +119,4 @@ lark-cli minutes +summary --minute-token obcnxxxxxxxxxxxxxxxxxxxx --summary @sum
|
||||
- [lark-minutes](../SKILL.md) — 妙记全部命令
|
||||
- [minutes +todo](lark-minutes-todo.md) — 替换待办项
|
||||
- [minutes +detail](lark-minutes-detail.md) — 读取总结、待办等 AI 产物
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
> **路由**:本命令操作**妙记内的 AI 待办**,不是飞书任务(Task)。用户说「在妙记里新建待办」时**必须**用本命令,**禁止**走 `lark-cli task` / `tasklists list` / `task +create`。详见 [lark-minutes/SKILL.md](../SKILL.md) 第 6 节。
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
对妙记中的待办做新增 / 更新 / 删除(单条或批量)。写操作。
|
||||
|
||||
@@ -134,3 +135,4 @@ lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --operation add -
|
||||
- [lark-minutes](../SKILL.md)
|
||||
- [minutes +summary](lark-minutes-summary.md)
|
||||
- [minutes +detail](lark-minutes-detail.md)
|
||||
- [lark-shared](../../lark-shared/SKILL.md)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# minutes +update
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
修改飞书妙记的标题(topic)。
|
||||
|
||||
@@ -37,3 +38,4 @@ lark-cli minutes +update --minute-token xxx --topic "周会纪要 2026-05-18"
|
||||
## 参考
|
||||
|
||||
- [lark-minutes](../SKILL.md) -- 妙记相关功能说明
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# minutes +upload
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
上传音视频文件到飞书妙记并生成妙记(Minute)。
|
||||
|
||||
@@ -30,12 +31,12 @@
|
||||
```
|
||||
- 命令执行成功后,将返回生成的妙记链接 `minute_url`。
|
||||
|
||||
3. **如需纪要 / 逐字稿 / 文字稿 / 撰写文字,使用返回的 `minute_token` 调用 `minutes +detail`**
|
||||
- 如果用户要的是纪要、逐字稿、文字稿、撰写文字、总结、待办或章节,使用上一步返回的 `minute_token` 继续调用:
|
||||
3. **如需纪要 / 逐字稿 / 文字稿 / 撰写文字,继续提取 `minute_token` 调用 `minutes +detail`**
|
||||
- 从返回的 `minute_url` 中提取路径最后一段,得到 `minute_token`。
|
||||
- 如果用户要的是纪要、逐字稿、文字稿、撰写文字、总结、待办或章节,继续调用:
|
||||
```bash
|
||||
lark-cli minutes +detail --minute-tokens <minute_token> --wait-ready --summary --todo --chapter --keyword --transcript
|
||||
lark-cli minutes +detail --minute-tokens <minute_token> --summary --todo --chapter --keyword --transcript
|
||||
```
|
||||
- `--wait-ready` 参数表示等待妙记生成完毕后再获取产物,上传后立即读取详情时必须加上此参数。
|
||||
- `minutes +detail --minute-tokens` 会返回妙记产物(总结、待办、章节、关键词、逐字稿);必要时还会把逐字稿落地到本地文件。
|
||||
|
||||
> **异步生成提示**:API 会立即返回 `minute_url`,但妙记可能仍在异步生成中,您可以直接通过该妙记链接查看当前的处理状态和转写结果。
|
||||
@@ -46,8 +47,8 @@
|
||||
# 通过已上传到云空间(云盘/云存储)的 file_token 生成妙记
|
||||
lark-cli minutes +upload --file-token boxcnxxxxxxxxxxxxxxxx
|
||||
|
||||
# 上传后立即获取妙记产物,需加 --wait-ready 等待生成完毕(--summary --todo --chapter --keyword --transcript 按需传入)
|
||||
lark-cli minutes +detail --minute-tokens obcnxxxxxxxxxxxxxxxx --wait-ready --summary
|
||||
# 通过 minute_token 继续获取妙记产物(--summary --todo --chapter --keyword --transcript 按需传入)
|
||||
lark-cli minutes +detail --minute-tokens obcnxxxxxxxxxxxxxxxx --summary
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -80,7 +81,7 @@ lark-cli minutes +detail --minute-tokens obcnxxxxxxxxxxxxxxxx --wait-ready --sum
|
||||
1. 使用 `lark-cli drive +upload --file <path>` 上传本地音视频文件到云空间(云盘/云存储)
|
||||
2. 从返回结果中取出 `file_token`
|
||||
3. 调用 `lark-cli minutes +upload --file-token <file_token>` 生成妙记
|
||||
4. 如果目标是纪要、逐字稿、文字稿、撰写文字、总结、待办或章节,使用返回的 `minute_token`,继续调用 `lark-cli minutes +detail --minute-tokens <minute_token> --wait-ready`
|
||||
4. 如果目标是纪要、逐字稿、文字稿、撰写文字、总结、待办或章节,再从 `minute_url` 提取 `minute_token`,继续调用 `lark-cli minutes +detail --minute-tokens <minute_token>`
|
||||
|
||||
> **边界说明**:`minutes +upload` 本身只负责把文件转成妙记并返回 `minute_url`。纪要内容、逐字稿、文字稿、撰写文字、总结、待办、章节属于后续产物获取,应由 [minutes +detail](lark-minutes-detail.md) 承接。
|
||||
|
||||
@@ -88,17 +89,16 @@ lark-cli minutes +detail --minute-tokens obcnxxxxxxxxxxxxxxxx --wait-ready --sum
|
||||
|
||||
```json
|
||||
{
|
||||
"minute_url": "http(s)://<host>/minutes/<minute-token>",
|
||||
"minute_token": "<minute-token>"
|
||||
"minute_url": "http(s)://<host>/minutes/<minute-token>"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `minute_url` | 生成的妙记访问链接 |
|
||||
| `minute_token` | 从 `minute_url` 提取出的妙记 Token,可直接传给 `minutes +detail --minute-tokens` |
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-minutes](../SKILL.md) -- 妙记相关功能说明
|
||||
- [drive +upload](../../lark-drive/references/lark-drive-upload.md) -- 上传文件到云空间(云盘/云存储)
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
|
||||
# vc +recording
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
通过 meeting_id 或 calendar_event_id 查询对应的 minute_token。这是 VC 域和 Minutes 域之间的桥梁命令。只读操作。
|
||||
|
||||
@@ -150,3 +151,4 @@ lark-cli minutes +download --minute-tokens <minute_token>
|
||||
- [lark-vc](../SKILL.md) — 视频会议全部命令
|
||||
- [lark-vc-search](lark-vc-search.md) — 搜索历史会议(获取 meeting_id)
|
||||
- [lark-minutes-detail](../../lark-minutes/references/lark-minutes-detail.md) — 获取会议纪要
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: lark-wiki
|
||||
version: 1.0.2
|
||||
version: 1.0.1
|
||||
description: "飞书知识库:管理知识空间、空间成员和文档节点。创建和查询知识空间、查看和管理空间成员、管理节点层级结构、在知识库中组织文档和快捷方式。当用户需要在知识库中查找或创建文档、浏览知识空间结构、查看或管理空间成员、移动或复制节点时使用。当用户给出 doubao.com 的 /wiki/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。不负责:上传文件到知识库节点下(走 lark-drive)、编辑文档/表格/Base 内容(走 lark-doc / lark-sheets / lark-base)。"
|
||||
metadata:
|
||||
requires:
|
||||
@@ -34,8 +34,6 @@ 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,9 +11,6 @@ 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
|
||||
|
||||
@@ -34,8 +31,8 @@ lark-cli wiki +node-list --space-id <SPACE_ID> --format pretty
|
||||
|
||||
| Flag | Type | Required | Default | Description |
|
||||
|------|------|----------|---------|-------------|
|
||||
| `--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 |
|
||||
| `--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 |
|
||||
| `--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`) |
|
||||
@@ -85,10 +82,6 @@ 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
|
||||
|
||||
|
||||
@@ -24,17 +24,7 @@ import (
|
||||
const EnvBinaryPath = "LARK_CLI_BIN"
|
||||
const projectRootMarkerDir = "tests"
|
||||
const cliBinaryName = "lark-cli"
|
||||
|
||||
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
|
||||
)
|
||||
const CleanupTimeout = 30 * time.Second
|
||||
|
||||
func SkipWithoutUserToken(t *testing.T) {
|
||||
t.Helper()
|
||||
@@ -112,34 +102,6 @@ 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
|
||||
@@ -149,25 +111,8 @@ 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
|
||||
@@ -241,16 +186,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 = defaultRetryAttempts
|
||||
opts.Attempts = 4
|
||||
}
|
||||
if opts.InitialDelay <= 0 {
|
||||
opts.InitialDelay = defaultRetryInitialDelay
|
||||
opts.InitialDelay = 1 * time.Second
|
||||
}
|
||||
if opts.MaxDelay <= 0 {
|
||||
opts.MaxDelay = defaultRetryMaxDelay
|
||||
opts.MaxDelay = 6 * time.Second
|
||||
}
|
||||
if opts.BackoffMultiple <= 1 {
|
||||
opts.BackoffMultiple = defaultRetryBackoffMultiple
|
||||
opts.BackoffMultiple = 2
|
||||
}
|
||||
if opts.ShouldRetry == nil {
|
||||
opts.ShouldRetry = func(result *Result) bool {
|
||||
@@ -261,7 +206,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 := runCmdOnce(ctx, req)
|
||||
result, err := RunCmd(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -289,63 +234,6 @@ 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()
|
||||
@@ -363,10 +251,6 @@ 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
|
||||
}
|
||||
@@ -387,11 +271,26 @@ func isCleanupSuppressedResult(result *Result) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
payload := extractJSONPayload(result.Stdout)
|
||||
if payload == "" {
|
||||
payload = extractJSONPayload(result.Stderr)
|
||||
raw := strings.TrimSpace(result.Stdout)
|
||||
if raw == "" {
|
||||
raw = strings.TrimSpace(result.Stderr)
|
||||
}
|
||||
if payload == "" {
|
||||
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) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -407,32 +306,6 @@ 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,12 +5,10 @@ package clie2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -225,88 +223,6 @@ 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 {
|
||||
@@ -344,35 +260,6 @@ 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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docs
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -23,7 +23,7 @@ func TestDocsFetchDryRunIgnoresAPIVersionCompatFlag(t *testing.T) {
|
||||
Args: []string{
|
||||
"docs", "+fetch",
|
||||
"--doc", "doxcnDryRunCompat",
|
||||
"--api-version", "v1",
|
||||
"--api-version", "legacy",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
@@ -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_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 +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 +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` |
|
||||
|
||||
@@ -14,18 +14,6 @@ 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 {
|
||||
@@ -72,18 +60,14 @@ 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.RunCmd(ctx, clie2e.Request{
|
||||
deleteResult, deleteErr := clie2e.RunCmdWithRetry(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
|
||||
}
|
||||
@@ -98,21 +82,35 @@ 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, visibilityWait); err != nil {
|
||||
return deleteResult, clie2e.CleanupWarning(
|
||||
fmt.Errorf("drive resource %s/%s still visible after accepted delete: %w", docType, token, err),
|
||||
)
|
||||
if err := WaitDriveResourceDeleted(ctx, token, docType, defaultAs); err != nil {
|
||||
return deleteResult, err
|
||||
}
|
||||
return deleteResult, nil
|
||||
}
|
||||
|
||||
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)
|
||||
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:
|
||||
}
|
||||
}
|
||||
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,13 +5,8 @@ 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"
|
||||
)
|
||||
|
||||
@@ -21,59 +16,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Mail CLI E2E Coverage
|
||||
|
||||
## Metrics
|
||||
- Denominator: 65 leaf commands
|
||||
- Covered: 16
|
||||
- Coverage: 24.6%
|
||||
- Denominator: 63 leaf commands
|
||||
- Covered: 14
|
||||
- Coverage: 22.2%
|
||||
|
||||
## Summary
|
||||
- TestMail_DraftLifecycleWorkflowAsUser: proves a self-contained user draft workflow across `mail user_mailboxes profile`, `mail +draft-create`, `mail user_mailbox.drafts list`, `mail user_mailbox.drafts get`, `mail +draft-edit`, and `mail user_mailbox.drafts delete`; key `t.Run(...)` proof points are `get mailbox profile as user`, `create draft with shortcut as user`, `list draft as user`, `get created draft as user`, `inspect created draft as user`, `update draft subject with shortcut as user`, `inspect updated draft as user`, `delete draft as user`, and `verify draft removed from list as user`.
|
||||
@@ -20,8 +20,6 @@
|
||||
| ✓ | mail +draft-send | shortcut | mail_draft_send_workflow_test.go::TestMail_DraftSendWorkflowAsUser/send draft with shortcut as user; mail_draft_send_dryrun_test.go::TestMail_DraftSendDryRun | `--draft-id`; `--mailbox me`; `--yes`; dry-run repeated/comma-separated `--draft-id` | sends a self-addressed draft through the batch shortcut and locks dry-run request shape |
|
||||
| ✓ | mail +forward | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/forward received message with shortcut as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/inspect forward draft as user | `--message-id`; `--to`; `--body`; `--plain-text` | uses self-generated inbox message as source and inspects forwarded draft projection |
|
||||
| ✓ | mail +message | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get sent message as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get received message as user | `--mailbox me`; `--message-id` | verifies both SENT and INBOX copies after self-send |
|
||||
| ✓ | mail +message-modify | shortcut | shortcuts/mail/mail_message_manage_test.go::TestMessageModify_DryRunShowsPlanWithoutValidationGET; shortcuts/mail/mail_message_manage_test.go::TestMessageModify_BatchesAndAggregatesPartialFailure | `--message-ids`; `--add-label-ids`; `--remove-label-ids`; `--add-folder`; `--dry-run` | unit/dry-run coverage locks validation, batching, request shape, and partial failure aggregation; live E2E needs controlled disposable messages/labels/folders |
|
||||
| ✓ | mail +message-trash | shortcut | shortcuts/mail/mail_message_manage_test.go::TestMessageTrash_RequiresYesAndBatches | `--message-ids`; `--yes`; `--dry-run` | unit coverage locks high-risk confirmation and batch_trash request shape; live E2E needs controlled disposable messages |
|
||||
| ✓ | mail +messages | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get both self sent messages as user | `--mailbox me`; `--message-ids` | batch reads both sent and received message copies |
|
||||
| ✓ | mail +reply | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/reply to received message with shortcut as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/inspect reply draft as user | `--message-id`; `--body`; `--plain-text` | creates reply draft from self-generated inbox message and inspects quoted content |
|
||||
| ✕ | mail +reply-all | shortcut | | none | self-send traffic leaves no stable non-self recipient set for deterministic reply-all assertions |
|
||||
|
||||
@@ -5,7 +5,6 @@ package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -204,11 +203,6 @@ 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
|
||||
@@ -339,34 +333,28 @@ func listWikiNodeChildren(ctx context.Context, spaceID, parentNodeToken string)
|
||||
}
|
||||
|
||||
func waitWikiNodeDeleted(ctx context.Context, nodeToken string) error {
|
||||
var lastTransientErr error
|
||||
deadline := time.NewTimer(20 * time.Second)
|
||||
defer deadline.Stop()
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
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) {
|
||||
for {
|
||||
deleted, err := isWikiNodeDeleted(ctx, nodeToken)
|
||||
if err != nil {
|
||||
if isWikiVerifyTransientError(err) {
|
||||
lastTransientErr = err
|
||||
return false, nil
|
||||
} else {
|
||||
return false, err
|
||||
}
|
||||
return err
|
||||
}
|
||||
if deleted {
|
||||
return true, nil
|
||||
return nil
|
||||
}
|
||||
return false, 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:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isWikiNodeDeleted(ctx context.Context, nodeToken string) (bool, error) {
|
||||
@@ -387,31 +375,9 @@ 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()
|
||||
@@ -438,55 +404,6 @@ 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()
|
||||
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package plugin_e2e
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// seededCatalogVersion is far newer than the embedded stub's 0.0.0, so the
|
||||
// runtime overlay in internal/registry unconditionally applies it.
|
||||
const seededCatalogVersion = "9.9.9"
|
||||
|
||||
// seededCatalogJSON is a remote_meta.json (registry.MergedRegistry) carrying one
|
||||
// obviously-synthetic service. Seeding it into a bare-module fork's on-disk cache
|
||||
// gives the runtime catalog real data WITHOUT any network, so a test can prove
|
||||
// SchemaCatalog() consults that runtime catalog (issue #1764) rather than the
|
||||
// embedded-only (empty stub) catalog. Fields mirror internal/meta.Service.
|
||||
const seededCatalogJSON = `{
|
||||
"version": "9.9.9",
|
||||
"services": [
|
||||
{
|
||||
"name": "plugine2e",
|
||||
"version": "v1",
|
||||
"title": "plugin_e2e synthetic service",
|
||||
"description": "synthetic fixture for the runtime-catalog test; not a real API",
|
||||
"servicePath": "/open-apis/plugine2e/v1",
|
||||
"resources": {
|
||||
"widgets": {
|
||||
"methods": {
|
||||
"get": {
|
||||
"id": "plugine2e.widgets.get",
|
||||
"path": "/open-apis/plugine2e/v1/widgets/:id",
|
||||
"httpMethod": "GET",
|
||||
"description": "synthetic read method",
|
||||
"risk": "read",
|
||||
"accessTokens": ["tenant"],
|
||||
"parameters": {
|
||||
"id": {"type": "string", "location": "path", "required": true, "description": "synthetic id"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// runWithSeededCatalog runs bin against a fresh LARKSUITE_CLI_CONFIG_DIR whose
|
||||
// cache already holds cacheJSON as remote_meta.json (plus a fresh, high-version
|
||||
// cache-meta so the overlay applies and the TTL never triggers a refetch). Remote
|
||||
// meta is left ON so the on-disk cache overlay is consulted, but a long
|
||||
// LARKSUITE_CLI_META_TTL keeps the run offline and deterministic. This models a
|
||||
// bare-module binary that has runtime metadata available from a warm cache.
|
||||
func runWithSeededCatalog(t *testing.T, bin, cacheJSON string, args ...string) result {
|
||||
t.Helper()
|
||||
cfg := t.TempDir()
|
||||
cacheDir := filepath.Join(cfg, "cache")
|
||||
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir cache dir: %v", err)
|
||||
}
|
||||
writeFile(t, filepath.Join(cacheDir, "remote_meta.json"), cacheJSON)
|
||||
writeFile(t, filepath.Join(cacheDir, "remote_meta.meta.json"),
|
||||
fmt.Sprintf(`{"last_check_at":%d,"version":%q,"brand":""}`, time.Now().Unix(), seededCatalogVersion))
|
||||
env := append(os.Environ(),
|
||||
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1",
|
||||
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1",
|
||||
"LARKSUITE_CLI_CONFIG_DIR="+cfg,
|
||||
"LARKSUITE_CLI_META_TTL=1000000",
|
||||
)
|
||||
return runWithEnv(t, bin, env, args...)
|
||||
}
|
||||
|
||||
// plainPlugin registers a minimal observer-only plugin with NO Restrict rule
|
||||
// -- unlike readonly_test.go's plugins, it cannot deny "schema" as
|
||||
// out-of-domain, so any failure the command produces below is the command's
|
||||
// own behavior against the empty stub catalog, not a policy denial.
|
||||
const plainPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("plain", "0.1.0").
|
||||
Observer(platform.After, "noop", platform.All(),
|
||||
func(_ context.Context, _ platform.Invocation) {}).
|
||||
FailOpen().
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// TestDegradeStubMetadataSchema pins the #1764 stub-metadata degrade path.
|
||||
// The clean tree embeds only the empty meta_data_default.json stub
|
||||
// (internal/registry/catalog.go's SchemaCatalog falls through to
|
||||
// RuntimeCatalog when EmbeddedServicesTyped() is empty), and run()'s isolated
|
||||
// environment disables the remote overlay fetch and points the cache dir at
|
||||
// an empty tmp dir, so cmd/schema/schema.go's runSchema sees
|
||||
// catalog.Services() == 0 unconditionally -- the exact "offline with a cold
|
||||
// cache, remote meta off" branch documented at cmd/schema/schema.go:96-101.
|
||||
//
|
||||
// Observed real output for both `schema` and `schema im.messages.reply`
|
||||
// (identical -- runSchema checks catalog.Services()==0 before parsing args):
|
||||
//
|
||||
// exit=2
|
||||
// stdout=(empty)
|
||||
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
|
||||
// "message":"No API metadata available",
|
||||
// "hint":"this binary has no embedded API metadata; run any command with
|
||||
// network access to the open platform once so metadata can be fetched and
|
||||
// cached"}}
|
||||
//
|
||||
// This is the PINNED "graceful degrade" criterion: a structured JSON envelope
|
||||
// (gjson.Valid, no "panic:" substring) carrying a validation/failed_precondition
|
||||
// error with an actionable hint, NOT the raw Go panic crash that
|
||||
// install_test.go's TestInstallMustBuildInitPanicCrashesBinary pins for a
|
||||
// genuinely broken plugin, and NOT an "Unknown"-shaped internal error.
|
||||
// Note: exit==2 alone does not prove "not a crash" -- a genuine Go panic also
|
||||
// exits 2. The two real discriminators against a crash are the absence of a
|
||||
// "panic:" substring in stderr and stderr being valid JSON (gjson.Valid); both
|
||||
// are asserted below.
|
||||
func TestDegradeStubMetadataSchema(t *testing.T) {
|
||||
bin := buildFork(t, "plain", plainPlugin)
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"schema root", []string{"schema"}},
|
||||
{"schema with path", []string{"schema", "im.messages.reply"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
res := run(t, bin, tc.args...)
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
if res.exit != 2 {
|
||||
t.Fatalf("exit=%d want 2 (graceful validation exit); stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
}
|
||||
if strings.Contains(res.stderr, "panic:") {
|
||||
t.Fatalf("stderr contains a raw Go panic trace, not a graceful degrade; stderr=%s", res.stderr)
|
||||
}
|
||||
if !gjson.Valid(res.stderr) {
|
||||
t.Fatalf("stderr not a structured JSON envelope: %s", res.stderr)
|
||||
}
|
||||
if got := gjson.Get(res.stderr, "error.type").String(); got != "validation" {
|
||||
t.Errorf("error.type=%q want validation", got)
|
||||
}
|
||||
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "failed_precondition" {
|
||||
t.Errorf("error.subtype=%q want failed_precondition", got)
|
||||
}
|
||||
if msg := gjson.Get(res.stderr, "error.message").String(); msg != "No API metadata available" {
|
||||
t.Errorf("error.message=%q want %q", msg, "No API metadata available")
|
||||
}
|
||||
if hint := gjson.Get(res.stderr, "error.hint").String(); !strings.Contains(hint, "no embedded API metadata") {
|
||||
t.Errorf("error.hint=%q want to contain %q", hint, "no embedded API metadata")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeCatalogResolvesSchema pins the PRIMARY #1764 fix: a bare-module fork
|
||||
// (embedded stub only) resolves `schema` against the RUNTIME catalog seeded from
|
||||
// the on-disk cache, not the embedded-only catalog. Before f0b6f35f the module
|
||||
// build read the embedded-only catalog and returned "Unknown service: <svc>" even
|
||||
// though the runtime registry had metadata; after it, registry.SchemaCatalog()
|
||||
// falls back to the merged runtime catalog and the lookup succeeds.
|
||||
//
|
||||
// This is the counterpart to TestDegradeStubMetadataSchema: that test pins the
|
||||
// cold-cache corner (no runtime data -> graceful "No API metadata available");
|
||||
// this one pins the warm-cache main path (runtime data present -> schema works),
|
||||
// so a regression that re-embeds the embedded-only lookup fails HERE with
|
||||
// "Unknown service" rather than silently passing.
|
||||
func TestRuntimeCatalogResolvesSchema(t *testing.T) {
|
||||
bin := buildFork(t, "plain", plainPlugin)
|
||||
res := runWithSeededCatalog(t, bin, seededCatalogJSON, "schema", "plugine2e")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
out := res.stdout + res.stderr
|
||||
if strings.Contains(out, "Unknown service") {
|
||||
t.Fatalf("schema returned \"Unknown service\" -> runtime catalog NOT consulted (issue #1764 regression); out=%s", out)
|
||||
}
|
||||
if strings.Contains(out, "No API metadata available") {
|
||||
t.Fatalf("schema saw no metadata -> the seeded runtime cache was not loaded; out=%s", out)
|
||||
}
|
||||
if res.exit != 0 {
|
||||
t.Fatalf("exit=%d want 0 (schema resolved from runtime catalog); stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
}
|
||||
if !strings.Contains(out, "plugine2e") {
|
||||
t.Errorf("schema output does not mention the seeded service; out=%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// credentialBlockPlugin registers a credential.Provider whose ResolveAccount
|
||||
// (and ResolveToken) unconditionally return a *credential.BlockError.
|
||||
// internal/credential/credential_provider.go's doResolveAccount returns this
|
||||
// error straight from the provider loop -- before any defaultAcct fallback
|
||||
// and, transitively, before the LarkClient/HttpClient phases that would issue
|
||||
// a real network call ever run (see internal/cmdutil/factory_default.go's
|
||||
// Phase 2 -> Phase 4 ordering).
|
||||
const credentialBlockPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
)
|
||||
|
||||
type blockProvider struct{}
|
||||
|
||||
func (blockProvider) Name() string { return "block-cred" }
|
||||
|
||||
func (blockProvider) ResolveAccount(ctx context.Context) (*credential.Account, error) {
|
||||
return nil, &credential.BlockError{Provider: "block-cred", Reason: "blocked for test"}
|
||||
}
|
||||
|
||||
func (blockProvider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
|
||||
return nil, &credential.BlockError{Provider: "block-cred", Reason: "blocked for test"}
|
||||
}
|
||||
|
||||
func init() {
|
||||
credential.Register(blockProvider{})
|
||||
}
|
||||
`
|
||||
|
||||
// TestSubsystemCredentialBlock pins the credential.BlockError offline effect.
|
||||
// Observed real output for `docs +fetch --doc nonexistent`, run twice across
|
||||
// separate `go test -count` invocations (byte-identical both times, unlike
|
||||
// the transport-abort case -- credential resolution happens once, before any
|
||||
// endpoint is chosen, so there is no varying destination URL to leak into the
|
||||
// message):
|
||||
//
|
||||
// exit=5
|
||||
// stdout=(empty)
|
||||
// stderr={"ok":false,"identity":"bot","error":{"type":"internal","subtype":"unknown",
|
||||
// "message":"blocked by block-cred: blocked for test"}}
|
||||
func TestSubsystemCredentialBlock(t *testing.T) {
|
||||
bin := buildFork(t, "credential-block", credentialBlockPlugin)
|
||||
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
if res.exit != 5 {
|
||||
t.Fatalf("exit=%d want 5; stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
}
|
||||
if !gjson.Valid(res.stderr) {
|
||||
t.Fatalf("stderr not JSON: %s", res.stderr)
|
||||
}
|
||||
if got := gjson.Get(res.stderr, "error.type").String(); got != "internal" {
|
||||
t.Errorf("error.type=%q want internal", got)
|
||||
}
|
||||
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "unknown" {
|
||||
t.Errorf("error.subtype=%q want unknown", got)
|
||||
}
|
||||
if msg := gjson.Get(res.stderr, "error.message").String(); msg != "blocked by block-cred: blocked for test" {
|
||||
t.Errorf("error.message=%q want %q", msg, "blocked by block-cred: blocked for test")
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package plugin_e2e
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestDiagnostics asserts the VERIFIED stdout shapes of the two policy/plugin
|
||||
// diagnostic commands on a fork carrying the readonly Restrict rule:
|
||||
// - `config policy show`: source_name == the plugin name that installed the
|
||||
// active rule.
|
||||
// - `config plugins show`: {"plugins":[{"name","version","capabilities",...,
|
||||
// "hooks":{...}}],"total":N} with the readonly plugin present.
|
||||
func TestDiagnostics(t *testing.T) {
|
||||
bin := buildFork(t, "readonly", readonlyPlugin)
|
||||
|
||||
pol := run(t, bin, "config", "policy", "show")
|
||||
if pol.exit != 0 || !gjson.Valid(pol.stdout) {
|
||||
t.Fatalf("policy show exit=%d stdout=%s stderr=%s", pol.exit, pol.stdout, pol.stderr)
|
||||
}
|
||||
if src := gjson.Get(pol.stdout, "source_name").String(); src != "readonly" {
|
||||
t.Errorf("policy source_name=%q want readonly (stdout=%s)", src, pol.stdout)
|
||||
}
|
||||
|
||||
plug := run(t, bin, "config", "plugins", "show")
|
||||
if plug.exit != 0 || !gjson.Valid(plug.stdout) {
|
||||
t.Fatalf("plugins show exit=%d stdout=%s", plug.exit, plug.stdout)
|
||||
}
|
||||
if total := gjson.Get(plug.stdout, "total").Int(); total < 1 {
|
||||
t.Errorf("plugins total=%d want >=1 (stdout=%s)", total, plug.stdout)
|
||||
}
|
||||
if name := gjson.Get(plug.stdout, "plugins.0.name").String(); name != "readonly" {
|
||||
t.Errorf("plugins.0.name=%q want readonly", name)
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package plugin_e2e exercises the extension/platform plugin contract the way a
|
||||
// real customer does: it builds a fork of lark-cli with a plugin blank-imported,
|
||||
// then runs that fork as a subprocess and asserts the real stderr/stdout
|
||||
// envelopes and exit codes. This is L4 coverage — the in-process unit and
|
||||
// integration tests (extension/..., cmd/...) assert Go error values in the test
|
||||
// process and structurally cannot observe envelope serialization, exit codes, or
|
||||
// the blank-import -> init -> Register -> InstallAll assembly chain.
|
||||
//
|
||||
// Mechanism (the "customer build", mirrors xcaddy's build mode):
|
||||
// 1. `git archive HEAD` a clean tree containing only committed files (so the
|
||||
// fork embeds the tracked meta_data stub, reproducing the bare-module state).
|
||||
// 2. Generate a customer module: go.mod (cli's requires + `replace` to the
|
||||
// archived tree) + go.sum copy + main.go (blank-imports the plugin package)
|
||||
// + plugin package (its init() calls platform.Register).
|
||||
// 3. `go build` the fork (offline-capable via the warm module cache), then run
|
||||
// it as a subprocess and assert.
|
||||
package plugin_e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// cleanTree is the git-archived, committed-only source tree of the repo under
|
||||
// test, shared by every fork build. Populated by TestMain (smoke_test.go) —
|
||||
// TestMain must live in a _test.go file to be recognized by `go test`, so the
|
||||
// entry point sits there while the rest of the harness mechanism lives here.
|
||||
var cleanTree string
|
||||
|
||||
// baseDir holds the archive tree plus every generated customer module.
|
||||
var baseDir string
|
||||
|
||||
// repoRoot resolves the lark-cli module root from the test's working directory
|
||||
// (which `go test` sets to the package dir, tests/plugin_e2e).
|
||||
func repoRoot() (string, error) {
|
||||
out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
// gitArchive extracts HEAD's committed tree into dst by streaming `git archive`
|
||||
// into `tar -x`. Only tracked files are included — gitignored build artifacts
|
||||
// (e.g. the fetched meta_data.json) are absent, exactly as a module consumer
|
||||
// would see them. It wires the two processes with an explicit pipe rather than a
|
||||
// shell, so dst never reaches a shell command line.
|
||||
func gitArchive(root, dst string) error {
|
||||
archive := exec.Command("git", "archive", "HEAD")
|
||||
archive.Dir = root
|
||||
extract := exec.Command("tar", "-x", "-C", dst)
|
||||
pipe, err := archive.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
extract.Stdin = pipe
|
||||
// Each process gets its own stderr buffer: os/exec spawns a copy goroutine
|
||||
// per command, so a shared strings.Builder would be written concurrently by
|
||||
// both (git archive and tar run in parallel) -- a data race, since
|
||||
// strings.Builder is not concurrency-safe.
|
||||
var archiveErr, extractErr strings.Builder
|
||||
archive.Stderr = &archiveErr
|
||||
extract.Stderr = &extractErr
|
||||
if err := extract.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := archive.Run(); err != nil {
|
||||
_ = extract.Wait()
|
||||
return fmt.Errorf("git archive: %w: %s", err, archiveErr.String())
|
||||
}
|
||||
if err := extract.Wait(); err != nil {
|
||||
return fmt.Errorf("tar extract: %w: %s", err, extractErr.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// builtForks caches fork binaries by name so identical forks are built once.
|
||||
var builtForks = map[string]string{}
|
||||
|
||||
// buildFork generates a customer module whose plugin package body is pluginSrc,
|
||||
// builds the fork, and returns the binary path. Forks are cached by name.
|
||||
func buildFork(t *testing.T, name, pluginSrc string) string {
|
||||
t.Helper()
|
||||
if bin, ok := builtForks[name]; ok {
|
||||
return bin
|
||||
}
|
||||
mod := filepath.Join(baseDir, "fork-"+name)
|
||||
if err := os.MkdirAll(filepath.Join(mod, "plugin"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir customer module: %v", err)
|
||||
}
|
||||
|
||||
// go.mod: reuse cli's require graph, rename the module, replace cli with the
|
||||
// local archived tree. This avoids `go mod tidy` (no network at test time).
|
||||
rawMod, err := os.ReadFile(filepath.Join(cleanTree, "go.mod"))
|
||||
if err != nil {
|
||||
t.Fatalf("read archived go.mod: %v", err)
|
||||
}
|
||||
gomod := strings.Replace(string(rawMod), "module github.com/larksuite/cli", "module larkcustomer", 1)
|
||||
gomod += "\nrequire github.com/larksuite/cli v0.0.0\n\nreplace github.com/larksuite/cli => " + cleanTree + "\n"
|
||||
writeFile(t, filepath.Join(mod, "go.mod"), gomod)
|
||||
|
||||
// go.sum: transitive dependency hashes are identical to cli's.
|
||||
rawSum, err := os.ReadFile(filepath.Join(cleanTree, "go.sum"))
|
||||
if err != nil {
|
||||
t.Fatalf("read archived go.sum: %v", err)
|
||||
}
|
||||
writeFile(t, filepath.Join(mod, "go.sum"), string(rawSum))
|
||||
|
||||
writeFile(t, filepath.Join(mod, "main.go"), customerMain)
|
||||
writeFile(t, filepath.Join(mod, "plugin", "plugin.go"), pluginSrc)
|
||||
|
||||
bin := filepath.Join(mod, "fork-bin")
|
||||
build := exec.Command("go", "build", "-o", bin, ".")
|
||||
build.Dir = mod
|
||||
// -mod=mod fixes require annotations copied from cli's go.mod; the default
|
||||
// GOPROXY resolves any dep missing from the cache (goproxy in CI/dev).
|
||||
build.Env = append(os.Environ(), "GOFLAGS=-mod=mod")
|
||||
if out, err := build.CombinedOutput(); err != nil {
|
||||
t.Fatalf("build fork %q failed: %v\n%s", name, err, out)
|
||||
}
|
||||
builtForks[name] = bin
|
||||
return bin
|
||||
}
|
||||
|
||||
const customerMain = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/cmd"
|
||||
_ "larkcustomer/plugin" // blank import triggers plugin init() -> platform.Register
|
||||
)
|
||||
|
||||
func main() { os.Exit(cmd.Execute()) }
|
||||
`
|
||||
|
||||
// result is a subprocess run outcome.
|
||||
type result struct {
|
||||
stdout string
|
||||
stderr string
|
||||
exit int
|
||||
}
|
||||
|
||||
// run executes the fork binary with args in an isolated, offline environment and
|
||||
// captures stdout/stderr/exit. Each call gets a fresh empty
|
||||
// LARKSUITE_CLI_CONFIG_DIR and LARKSUITE_CLI_REMOTE_META=off, so the fork never
|
||||
// inherits the host's ~/.lark-cli cache or makes a startup metadata fetch to the
|
||||
// open platform. That reproduces the bare-module customer state (no embedded
|
||||
// metadata, cold cache) deterministically on any machine, including CI: without
|
||||
// it, whether a command's assertion is reached depends on whether a live network
|
||||
// fetch happened to succeed. Tests that need runtime metadata seed it explicitly
|
||||
// via runWithSeededCatalog.
|
||||
func run(t *testing.T, bin string, args ...string) result {
|
||||
t.Helper()
|
||||
return runWithEnv(t, bin, isolatedEnv(t), args...)
|
||||
}
|
||||
|
||||
// isolatedEnv is the bare-module, offline environment shared by run() and (as a
|
||||
// base) by runWithSeededCatalog.
|
||||
func isolatedEnv(t *testing.T) []string {
|
||||
t.Helper()
|
||||
return append(os.Environ(),
|
||||
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1",
|
||||
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1",
|
||||
"LARKSUITE_CLI_CONFIG_DIR="+t.TempDir(),
|
||||
"LARKSUITE_CLI_REMOTE_META=off",
|
||||
)
|
||||
}
|
||||
|
||||
// runWithEnv runs bin as a subprocess with the given full environment, capturing
|
||||
// stdout/stderr/exit.
|
||||
func runWithEnv(t *testing.T, bin string, env []string, args ...string) result {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
c := exec.CommandContext(ctx, bin, args...)
|
||||
c.Env = env
|
||||
var stdout, stderr strings.Builder
|
||||
c.Stdout = &stdout
|
||||
c.Stderr = &stderr
|
||||
err := c.Run()
|
||||
exit := 0
|
||||
if err != nil {
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) {
|
||||
exit = ee.ExitCode()
|
||||
} else {
|
||||
t.Fatalf("run %v: %v", args, err)
|
||||
}
|
||||
}
|
||||
return result{stdout: stdout.String(), stderr: stderr.String(), exit: exit}
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package plugin_e2e
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// multipleRestrictPlugin registers TWO distinct plugins that each call
|
||||
// Restrict() with an independently valid Rule. cmdpolicy.Resolve rejects
|
||||
// more than one distinct Restrict-owner regardless of each rule's own
|
||||
// validity (internal/cmdpolicy/resolver.go's distinctOwners check runs
|
||||
// before ValidateRule).
|
||||
const multipleRestrictPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("restrict-a", "0.1.0").
|
||||
Restrict(&platform.Rule{
|
||||
Name: "a-rule",
|
||||
Allow: []string{"docs/**"},
|
||||
MaxRisk: platform.RiskRead,
|
||||
}).
|
||||
MustBuild())
|
||||
platform.Register(
|
||||
platform.NewPlugin("restrict-b", "0.1.0").
|
||||
Restrict(&platform.Rule{
|
||||
Name: "b-rule",
|
||||
Allow: []string{"im/**"},
|
||||
MaxRisk: platform.RiskRead,
|
||||
}).
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// TestInstallMultipleRestrictPluginsPin pins reason_code=multiple_restrict_plugins.
|
||||
// Observed real output (any command, e.g. "schema" -- the fatal guard walks
|
||||
// every RunE in the tree so it fires regardless of which command runs):
|
||||
//
|
||||
// exit=2
|
||||
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
|
||||
// "message":"multiple plugins called Restrict; only one plugin may own the
|
||||
// policy: [restrict-a restrict-b]",
|
||||
// "hint":"plugin policy configuration is broken (reason_code
|
||||
// multiple_restrict_plugins); fix the plugin's Restrict rule or remove the
|
||||
// conflicting plugin"}}
|
||||
func TestInstallMultipleRestrictPluginsPin(t *testing.T) {
|
||||
bin := buildFork(t, "multiple-restrict", multipleRestrictPlugin)
|
||||
res := run(t, bin, "schema")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
assertReasonCodeEnvelope(t, res, "multiple_restrict_plugins")
|
||||
}
|
||||
|
||||
// invalidRulePlugin registers a single plugin whose Restrict Rule carries a
|
||||
// syntactically-invalid MaxRisk value. Neither the Builder nor the staging
|
||||
// Registrar validate Rule *contents* (only nilness) -- semantic validation
|
||||
// happens later, in cmdpolicy.ValidateRule, called from
|
||||
// cmd/platform_bootstrap.go's applyUserPolicyPruning -> cmdpolicy.Resolve.
|
||||
const invalidRulePlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("invalid-rule", "0.1.0").
|
||||
Restrict(&platform.Rule{
|
||||
Name: "bad-risk",
|
||||
Allow: []string{"docs/**"},
|
||||
MaxRisk: platform.Risk("bogus"),
|
||||
}).
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// TestInstallInvalidRulePin pins reason_code=invalid_rule. Observed real output
|
||||
// (schema):
|
||||
//
|
||||
// exit=2
|
||||
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
|
||||
// "message":"plugin \"invalid-rule\" rule invalid: invalid max_risk \"bogus\":
|
||||
// must be one of read|write|high-risk-write",
|
||||
// "hint":"plugin policy configuration is broken (reason_code invalid_rule);
|
||||
// fix the plugin's Restrict rule or remove the conflicting plugin"}}
|
||||
func TestInstallInvalidRulePin(t *testing.T) {
|
||||
bin := buildFork(t, "invalid-rule", invalidRulePlugin)
|
||||
res := run(t, bin, "schema")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
assertReasonCodeEnvelope(t, res, "invalid_rule")
|
||||
}
|
||||
|
||||
// installFailedPlugin is a hand-written bare platform.Plugin (not
|
||||
// Builder-based -- Install returning a plain error is not expressible
|
||||
// through the Builder's fluent API) whose Install always returns an error.
|
||||
// FailurePolicy=FailClosed makes the host abort rather than warn+skip.
|
||||
const installFailedPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
type installFailed struct{}
|
||||
|
||||
func (installFailed) Name() string { return "install-failed" }
|
||||
func (installFailed) Version() string { return "0.1.0" }
|
||||
func (installFailed) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{FailurePolicy: platform.FailClosed}
|
||||
}
|
||||
func (installFailed) Install(r platform.Registrar) error {
|
||||
return errors.New("deliberate install failure")
|
||||
}
|
||||
|
||||
func init() { platform.Register(installFailed{}) }
|
||||
`
|
||||
|
||||
// TestInstallFailedPin pins reason_code=install_failed. Observed real output
|
||||
// (schema):
|
||||
//
|
||||
// exit=2
|
||||
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
|
||||
// "message":"plugin \"install-failed\" (install_failed): Install returned
|
||||
// error: deliberate install failure",
|
||||
// "hint":"plugin \"install-failed\" failed to install (reason_code
|
||||
// install_failed); fix or remove the plugin before running commands"}}
|
||||
func TestInstallFailedPin(t *testing.T) {
|
||||
bin := buildFork(t, "install-failed", installFailedPlugin)
|
||||
res := run(t, bin, "schema")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
assertReasonCodeEnvelope(t, res, "install_failed")
|
||||
}
|
||||
|
||||
// installPanicPlugin is a hand-written bare Plugin whose Install panics.
|
||||
// safeCallInstall (internal/platform/host.go) recovers and converts the
|
||||
// panic into a typed install_panic error rather than crashing the binary.
|
||||
const installPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
type installPanic struct{}
|
||||
|
||||
func (installPanic) Name() string { return "install-panic" }
|
||||
func (installPanic) Version() string { return "0.1.0" }
|
||||
func (installPanic) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{FailurePolicy: platform.FailClosed}
|
||||
}
|
||||
func (installPanic) Install(r platform.Registrar) error {
|
||||
panic("deliberate install panic")
|
||||
}
|
||||
|
||||
func init() { platform.Register(installPanic{}) }
|
||||
`
|
||||
|
||||
// TestInstallPanicPin pins reason_code=install_panic. Observed real output
|
||||
// (schema):
|
||||
//
|
||||
// exit=2
|
||||
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
|
||||
// "message":"plugin \"install-panic\" (install_panic): Install panicked:
|
||||
// deliberate install panic",
|
||||
// "hint":"plugin \"install-panic\" failed to install (reason_code
|
||||
// install_panic); fix or remove the plugin before running commands"}}
|
||||
func TestInstallPanicPin(t *testing.T) {
|
||||
bin := buildFork(t, "install-panic", installPanicPlugin)
|
||||
res := run(t, bin, "schema")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
assertReasonCodeEnvelope(t, res, "install_panic")
|
||||
}
|
||||
|
||||
// pluginNamePanicPlugin is a hand-written bare Plugin whose Name() panics.
|
||||
// InstallAll's outer loop calls safeCallName BEFORE it ever reads
|
||||
// Capabilities(), so this aborts unconditionally regardless of what
|
||||
// Capabilities() would have declared (host.go's isUntrustedConfigError
|
||||
// list) -- Capabilities() here is a throwaway zero value, never invoked.
|
||||
const pluginNamePanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
type pluginNamePanic struct{}
|
||||
|
||||
func (pluginNamePanic) Name() string { panic("deliberate name panic") }
|
||||
func (pluginNamePanic) Version() string { return "0.1.0" }
|
||||
func (pluginNamePanic) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{}
|
||||
}
|
||||
func (pluginNamePanic) Install(r platform.Registrar) error { return nil }
|
||||
|
||||
func init() { platform.Register(pluginNamePanic{}) }
|
||||
`
|
||||
|
||||
// TestInstallPluginNamePanicPin pins reason_code=plugin_name_panic. Observed real
|
||||
// output (schema):
|
||||
//
|
||||
// exit=2
|
||||
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
|
||||
// "message":"plugin \"<unknown>\" (plugin_name_panic): Plugin.Name()
|
||||
// panicked: deliberate name panic",
|
||||
// "hint":"plugin \"<unknown>\" failed to install (reason_code
|
||||
// plugin_name_panic); fix or remove the plugin before running commands"}}
|
||||
func TestInstallPluginNamePanicPin(t *testing.T) {
|
||||
bin := buildFork(t, "plugin-name-panic", pluginNamePanicPlugin)
|
||||
res := run(t, bin, "schema")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
assertReasonCodeEnvelope(t, res, "plugin_name_panic")
|
||||
}
|
||||
|
||||
// capabilitiesPanicPlugin is a hand-written bare Plugin whose Capabilities()
|
||||
// panics. readFailurePolicy (internal/platform/host.go) re-invokes
|
||||
// Capabilities() to decide FailOpen vs FailClosed, panics again, and its
|
||||
// recover leaves the pre-set FailClosed default in place -- so this aborts
|
||||
// unconditionally too, without the plugin ever declaring a real policy.
|
||||
const capabilitiesPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
type capabilitiesPanic struct{}
|
||||
|
||||
func (capabilitiesPanic) Name() string { return "capabilities-panic" }
|
||||
func (capabilitiesPanic) Version() string { return "0.1.0" }
|
||||
func (capabilitiesPanic) Capabilities() platform.Capabilities {
|
||||
panic("deliberate capabilities panic")
|
||||
}
|
||||
func (capabilitiesPanic) Install(r platform.Registrar) error { return nil }
|
||||
|
||||
func init() { platform.Register(capabilitiesPanic{}) }
|
||||
`
|
||||
|
||||
// TestInstallCapabilitiesPanicPin pins reason_code=capabilities_panic. Observed
|
||||
// real output (schema):
|
||||
//
|
||||
// exit=2
|
||||
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
|
||||
// "message":"plugin \"capabilities-panic\" (capabilities_panic):
|
||||
// Plugin.Capabilities() panicked: deliberate capabilities panic",
|
||||
// "hint":"plugin \"capabilities-panic\" failed to install (reason_code
|
||||
// capabilities_panic); fix or remove the plugin before running commands"}}
|
||||
func TestInstallCapabilitiesPanicPin(t *testing.T) {
|
||||
bin := buildFork(t, "capabilities-panic", capabilitiesPanicPlugin)
|
||||
res := run(t, bin, "schema")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
assertReasonCodeEnvelope(t, res, "capabilities_panic")
|
||||
}
|
||||
|
||||
// restrictsMismatchPlugin is a hand-written bare Plugin that declares
|
||||
// Capabilities.Restricts=true (paired with the required FailClosed) but
|
||||
// whose Install never calls r.Restrict. stagingRegistrar.validateSelf
|
||||
// (internal/platform/staging.go) checks this exact declared-vs-actual
|
||||
// consistency after Install returns.
|
||||
const restrictsMismatchPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
type restrictsMismatch struct{}
|
||||
|
||||
func (restrictsMismatch) Name() string { return "restricts-mismatch" }
|
||||
func (restrictsMismatch) Version() string { return "0.1.0" }
|
||||
func (restrictsMismatch) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{Restricts: true, FailurePolicy: platform.FailClosed}
|
||||
}
|
||||
func (restrictsMismatch) Install(r platform.Registrar) error { return nil }
|
||||
|
||||
func init() { platform.Register(restrictsMismatch{}) }
|
||||
`
|
||||
|
||||
// TestInstallRestrictsMismatchPin pins reason_code=restricts_mismatch.
|
||||
// Observed real output (schema):
|
||||
//
|
||||
// exit=2
|
||||
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
|
||||
// "message":"plugin \"restricts-mismatch\" (restricts_mismatch):
|
||||
// Capabilities.Restricts=true but Install did not call r.Restrict",
|
||||
// "hint":"plugin \"restricts-mismatch\" failed to install (reason_code
|
||||
// restricts_mismatch); fix or remove the plugin before running commands"}}
|
||||
func TestInstallRestrictsMismatchPin(t *testing.T) {
|
||||
bin := buildFork(t, "restricts-mismatch", restrictsMismatchPlugin)
|
||||
res := run(t, bin, "schema")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
assertReasonCodeEnvelope(t, res, "restricts_mismatch")
|
||||
}
|
||||
|
||||
// mustBuildPanicPlugin calls MustBuild() on a Builder with an invalid plugin
|
||||
// name ("BadName!!" fails ^[a-z0-9][a-z0-9-]*$). This panics from
|
||||
// plugin.init(), which runs from the blank-import BEFORE main() has a
|
||||
// chance to install any recover-and-envelope guard -- so, unlike every
|
||||
// other case here, this crashes the process outright: no JSON envelope,
|
||||
// non-zero exit, a raw Go panic trace on stderr.
|
||||
const mustBuildPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
func init() {
|
||||
platform.Register(platform.NewPlugin("BadName!!", "0.1.0").MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// TestInstallMustBuildInitPanicCrashesBinary pins the MustBuild init-panic crash
|
||||
// shape. This is NOT the plugin_install envelope -- it is a bare Go panic
|
||||
// crash, because it happens in init(), before main()'s recover guard
|
||||
// exists. Observed real output (schema):
|
||||
//
|
||||
// exit=2
|
||||
// stderr=panic: plugin "BadName!!": invalid plugin name "BadName!!": must
|
||||
// match ^[a-z0-9][a-z0-9-]*$
|
||||
//
|
||||
// goroutine 1 [running]:
|
||||
// larkcustomer/plugin.init.0(...)
|
||||
// .../plugin/plugin.go:7
|
||||
// ...
|
||||
func TestInstallMustBuildInitPanicCrashesBinary(t *testing.T) {
|
||||
bin := buildFork(t, "mustbuild-panic", mustBuildPanicPlugin)
|
||||
res := run(t, bin, "schema")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
if res.exit == 0 {
|
||||
t.Fatalf("expected non-zero exit on init panic; exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
}
|
||||
if gjson.Valid(res.stderr) {
|
||||
t.Fatalf("expected a raw panic trace, not a JSON envelope; stderr=%s", res.stderr)
|
||||
}
|
||||
if !strings.Contains(res.stderr, "panic:") {
|
||||
t.Fatalf("stderr missing Go panic trace; stderr=%s", res.stderr)
|
||||
}
|
||||
if !strings.Contains(res.stderr, `invalid plugin name "BadName!!"`) {
|
||||
t.Errorf("stderr missing the Builder's invalid-name message; stderr=%s", res.stderr)
|
||||
}
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package plugin_e2e
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// auditPlugin registers a single After observer matching every command that
|
||||
// logs "[audit] <path>" to stderr. Based on (a simplified form of) the
|
||||
// shipped extension/platform/examples/audit-observer example.
|
||||
const auditPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("audit", "0.1.0").
|
||||
Observer(platform.After, "log", platform.All(),
|
||||
func(_ context.Context, inv platform.Invocation) {
|
||||
fmt.Fprintf(os.Stderr, "[audit] %s\n", inv.Cmd().Path())
|
||||
}).
|
||||
FailOpen().
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// TestObservePin pins the audit observer's stderr line format. Observed
|
||||
// real output (docs +fetch --doc nonexistent, a real read-risk command that
|
||||
// fails downstream with an API error unrelated to the plugin):
|
||||
//
|
||||
// exit=1
|
||||
// stderr=[audit] docs/+fetch
|
||||
// {"ok":false,"identity":"user","error":{"type":"api","subtype":"unknown",...}}
|
||||
//
|
||||
// The observer line always leads, on its own line, before whatever the
|
||||
// command itself writes to stderr.
|
||||
func TestObservePin(t *testing.T) {
|
||||
bin := buildFork(t, "audit", auditPlugin)
|
||||
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
|
||||
if !strings.Contains(res.stderr, "[audit] docs/+fetch\n") {
|
||||
t.Fatalf("stderr missing audit line; stderr=%s", res.stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// auditRestrictPlugin combines an After observer with a Restrict rule in one
|
||||
// plugin, so a denied command's stderr carries both the observer's
|
||||
// side-effect and the denial envelope: the framework's contract is that
|
||||
// After observers fire even for denied commands (see
|
||||
// extension/platform/invocation.go's DeniedByPolicy doc).
|
||||
const auditRestrictPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("audit-restrict", "0.1.0").
|
||||
Observer(platform.After, "log", platform.All(),
|
||||
func(_ context.Context, inv platform.Invocation) {
|
||||
fmt.Fprintf(os.Stderr, "[audit] %s\n", inv.Cmd().Path())
|
||||
}).
|
||||
Restrict(&platform.Rule{
|
||||
Name: "agent-readonly",
|
||||
Allow: []string{"docs/**", "im/**"},
|
||||
MaxRisk: platform.RiskRead,
|
||||
}).
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// TestObserveOnDeniedPin pins the audit-contract case: a denied command's
|
||||
// stderr carries BOTH the observer's audit line AND the denial envelope,
|
||||
// concatenated in a single stream, audit line first. Observed real output
|
||||
// (docs +update --doc-token x --content y, denied write_not_allowed):
|
||||
//
|
||||
// exit=2
|
||||
// stderr=[audit] docs/+update
|
||||
// {"ok":false,"error":{"type":"validation","subtype":"failed_precondition",...}}
|
||||
//
|
||||
// The leading "[audit] ..." line means gjson.Valid on the raw stderr is
|
||||
// false; the JSON envelope must be sliced out from the first '{' before
|
||||
// parsing it as JSON.
|
||||
func TestObserveOnDeniedPin(t *testing.T) {
|
||||
bin := buildFork(t, "audit-restrict", auditRestrictPlugin)
|
||||
res := run(t, bin, "docs", "+update", "--doc-token", "x", "--content", "y")
|
||||
if res.exit != 2 {
|
||||
t.Fatalf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
}
|
||||
if !strings.Contains(res.stderr, "[audit] docs/+update\n") {
|
||||
t.Fatalf("stderr missing audit line on a denied command; stderr=%s", res.stderr)
|
||||
}
|
||||
i := strings.Index(res.stderr, "{")
|
||||
if i < 0 {
|
||||
t.Fatalf("stderr has no JSON envelope after the audit line; stderr=%s", res.stderr)
|
||||
}
|
||||
envelope := res.stderr[i:]
|
||||
if !gjson.Valid(envelope) {
|
||||
t.Fatalf("sliced envelope not JSON: %s", envelope)
|
||||
}
|
||||
if got := gjson.Get(envelope, "error.type").String(); got != "validation" {
|
||||
t.Errorf("error.type=%q want validation", got)
|
||||
}
|
||||
if got := gjson.Get(envelope, "error.subtype").String(); got != "failed_precondition" {
|
||||
t.Errorf("error.subtype=%q want failed_precondition", got)
|
||||
}
|
||||
if hint := gjson.Get(envelope, "error.hint").String(); !strings.Contains(hint, "reason_code write_not_allowed") {
|
||||
t.Errorf("hint=%q want to contain reason_code write_not_allowed", hint)
|
||||
}
|
||||
}
|
||||
|
||||
// observerPanicPlugin's After observer panics unconditionally. runObserverSafe
|
||||
// (internal/hook/install.go) must isolate the panic so command dispatch
|
||||
// still completes normally.
|
||||
const observerPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("observer-panic", "0.1.0").
|
||||
Observer(platform.After, "log", platform.All(),
|
||||
func(_ context.Context, _ platform.Invocation) {
|
||||
panic("boom")
|
||||
}).
|
||||
FailOpen().
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// TestObserverPanicIsolationPin pins panic isolation: an After observer that
|
||||
// always panics must not affect the command's own outcome. The assertion is
|
||||
// baseline-relative -- the panicking-observer fork's exit code must equal the
|
||||
// noop-observer baseline fork's for the same `schema` command (a local,
|
||||
// network-free, read-risk command), whatever that shared exit code is.
|
||||
// Observed real output at pin time:
|
||||
//
|
||||
// panicking: exit=0 stderr=warning: hook "observer-panic.log" panicked: boom
|
||||
// baseline: exit=0 stderr=(empty)
|
||||
//
|
||||
// The panic is fully swallowed by runObserverSafe (internal/hook/install.go),
|
||||
// surfacing only as a stderr warning line, never as a non-zero exit or crash.
|
||||
func TestObserverPanicIsolationPin(t *testing.T) {
|
||||
bin := buildFork(t, "observer-panic", observerPanicPlugin)
|
||||
res := run(t, bin, "schema")
|
||||
|
||||
baselineBin := buildFork(t, "smoke", noopPlugin)
|
||||
baseline := run(t, baselineBin, "schema")
|
||||
|
||||
if res.exit != baseline.exit {
|
||||
t.Fatalf("panicking-observer exit=%d differs from baseline exit=%d; stderr=%s", res.exit, baseline.exit, res.stderr)
|
||||
}
|
||||
if !strings.Contains(res.stderr, `warning: hook "observer-panic.log" panicked: boom`) {
|
||||
t.Errorf("stderr missing panic-isolation warning; stderr=%s", res.stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// wrapAbortPlugin's Wrapper short-circuits every command with an AbortError
|
||||
// instead of calling next.
|
||||
const wrapAbortPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("wrap-abort", "0.1.0").
|
||||
Wrap("guard", platform.All(), func(next platform.Handler) platform.Handler {
|
||||
return func(ctx context.Context, inv platform.Invocation) error {
|
||||
return &platform.AbortError{
|
||||
HookName: "guard",
|
||||
Reason: "blocked for test",
|
||||
}
|
||||
}
|
||||
}).
|
||||
FailOpen().
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// TestWrapAbortPin pins the wrap-abort envelope shape. An *AbortError
|
||||
// returned by a Wrapper is converted by wrapAbortError
|
||||
// (internal/hook/install.go) into the SAME envelope shape as a Restrict
|
||||
// denial -- error.type=="validation", error.subtype=="failed_precondition"
|
||||
// -- NOT a distinct "hook" error type. Observed real output (docs +fetch
|
||||
// --doc nonexistent, wrapper aborts unconditionally before calling next):
|
||||
//
|
||||
// exit=2
|
||||
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
|
||||
// "message":"hook \"wrap-abort.guard\" aborted: blocked for test",
|
||||
// "hint":"plugin hook \"wrap-abort.guard\" aborted this command; adjust the
|
||||
// request to satisfy the hook's policy, or remove the plugin"}}
|
||||
//
|
||||
// HookName is namespaced to "<plugin-name>.<hookName>" ("wrap-abort.guard")
|
||||
// regardless of the HookName the plugin set on the AbortError itself
|
||||
// (namespacedWrap overwrites it) -- see internal/hook/install.go.
|
||||
func TestWrapAbortPin(t *testing.T) {
|
||||
bin := buildFork(t, "wrap-abort", wrapAbortPlugin)
|
||||
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
|
||||
if res.exit != 2 {
|
||||
t.Fatalf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
}
|
||||
if !gjson.Valid(res.stderr) {
|
||||
t.Fatalf("stderr not JSON: %s", res.stderr)
|
||||
}
|
||||
if got := gjson.Get(res.stderr, "error.type").String(); got != "validation" {
|
||||
t.Errorf("error.type=%q want validation", got)
|
||||
}
|
||||
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "failed_precondition" {
|
||||
t.Errorf("error.subtype=%q want failed_precondition", got)
|
||||
}
|
||||
if msg := gjson.Get(res.stderr, "error.message").String(); !strings.Contains(msg, `hook "wrap-abort.guard" aborted: blocked for test`) {
|
||||
t.Errorf("error.message=%q want to contain the namespaced hook name and Reason", msg)
|
||||
}
|
||||
if hint := gjson.Get(res.stderr, "error.hint").String(); !strings.Contains(hint, `plugin hook "wrap-abort.guard" aborted this command`) {
|
||||
t.Errorf("error.hint=%q want to contain the abort hint", hint)
|
||||
}
|
||||
}
|
||||
|
||||
// wrapPanicPlugin's Wrapper factory panics on every invocation (the factory
|
||||
// closure itself, not the returned Handler).
|
||||
const wrapPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("wrap-panic", "0.1.0").
|
||||
Wrap("guard", platform.All(), func(next platform.Handler) platform.Handler {
|
||||
panic("wrap boom")
|
||||
}).
|
||||
FailOpen().
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// TestWrapPanicPin pins the wrap-panic envelope shape: a panicking Wrapper
|
||||
// factory does not crash the process. recoverWrap (internal/hook/install.go)
|
||||
// converts the panic into the same validation/failed_precondition shape as
|
||||
// wrap-abort, with a distinct message/hint pair. Observed real output (docs
|
||||
// +fetch --doc nonexistent, wrapper factory panics unconditionally):
|
||||
//
|
||||
// exit=2
|
||||
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
|
||||
// "message":"hook \"wrap-panic.guard\" panicked: wrap boom",
|
||||
// "hint":"plugin hook \"wrap-panic.guard\" crashed while handling this
|
||||
// command; report the panic to the plugin author or remove the plugin"}}
|
||||
func TestWrapPanicPin(t *testing.T) {
|
||||
bin := buildFork(t, "wrap-panic", wrapPanicPlugin)
|
||||
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
|
||||
if res.exit != 2 {
|
||||
t.Fatalf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
}
|
||||
if !gjson.Valid(res.stderr) {
|
||||
t.Fatalf("stderr not JSON: %s", res.stderr)
|
||||
}
|
||||
if got := gjson.Get(res.stderr, "error.type").String(); got != "validation" {
|
||||
t.Errorf("error.type=%q want validation", got)
|
||||
}
|
||||
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "failed_precondition" {
|
||||
t.Errorf("error.subtype=%q want failed_precondition", got)
|
||||
}
|
||||
if msg := gjson.Get(res.stderr, "error.message").String(); !strings.Contains(msg, `hook "wrap-panic.guard" panicked: wrap boom`) {
|
||||
t.Errorf("error.message=%q want to contain the namespaced hook name and panic value", msg)
|
||||
}
|
||||
if hint := gjson.Get(res.stderr, "error.hint").String(); !strings.Contains(hint, `plugin hook "wrap-panic.guard" crashed while handling this command`) {
|
||||
t.Errorf("error.hint=%q want to contain the panic hint", hint)
|
||||
}
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package plugin_e2e
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// readonlyPlugin registers a Restrict rule that only allows read-risk
|
||||
// commands under the docs/** and im/** domains. It mirrors the official
|
||||
// example readonly-policy configuration.
|
||||
const readonlyPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("readonly", "0.1.0").
|
||||
Restrict(&platform.Rule{
|
||||
Name: "agent-readonly",
|
||||
Allow: []string{"docs/**", "im/**"},
|
||||
MaxRisk: platform.RiskRead,
|
||||
}).
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// TestReadonlyDenial asserts the VERIFIED denial envelope shape: stderr is
|
||||
// valid JSON, error.type=="validation", error.subtype=="failed_precondition",
|
||||
// error.hint contains the literal "reason_code <X>" substring, and the
|
||||
// process exits 2. reason_code lives only in the hint string, not a
|
||||
// structured field.
|
||||
func TestReadonlyDenial(t *testing.T) {
|
||||
bin := buildFork(t, "readonly", readonlyPlugin)
|
||||
// Note: reason_code mixed_children_policy is intentionally NOT covered here.
|
||||
// It requires a parent command whose *enumerated children* have mixed
|
||||
// allow/deny outcomes, which needs the full command tree from API metadata.
|
||||
// This L4 harness builds a bare-module fork (embedded stub only), so offline
|
||||
// a parent like "sheets" has no known children and collapses to
|
||||
// domain_not_allowed -- identical to the "leaf out of allow list" case and
|
||||
// not a distinct reason_code. Covered instead by the in-process cmdpolicy
|
||||
// unit tests, which construct a mixed-children tree directly.
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
reasonCode string
|
||||
}{
|
||||
{"write in allowed domain", []string{"docs", "+update", "--doc-token", "x", "--content", "y"}, "write_not_allowed"},
|
||||
{"leaf out of allow list", []string{"schema"}, "domain_not_allowed"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertReasonCodeEnvelope(t, run(t, bin, tc.args...), tc.reasonCode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadonlyAllows asserts the allow-path: a read command inside an
|
||||
// allowed domain must NOT be denied by the policy gate. It may still fail
|
||||
// downstream (e.g. api/auth error), but that failure must not carry the
|
||||
// denial envelope shape and must not exit 2.
|
||||
func TestReadonlyAllows(t *testing.T) {
|
||||
bin := buildFork(t, "readonly", readonlyPlugin)
|
||||
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
|
||||
if res.exit == 2 {
|
||||
t.Fatalf("read command was denied (exit=2); stderr=%s", res.stderr)
|
||||
}
|
||||
if gjson.Valid(res.stderr) && gjson.Get(res.stderr, "error.subtype").String() == "failed_precondition" {
|
||||
t.Errorf("read command produced a denial envelope; stderr=%s", res.stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// identityPlugin registers a Restrict rule scoped to bot identities only.
|
||||
// im +messages-search declares AuthTypes:["user"] (see
|
||||
// shortcuts/im/im_messages_search.go), so it has no intersection with the
|
||||
// rule's bot-only whitelist regardless of which --as value the caller
|
||||
// passes: platform.Rule.Identities is checked against the command's own
|
||||
// static supported-identities annotation, not the runtime --as flag.
|
||||
const identityPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("identity-restrict", "0.1.0").
|
||||
Restrict(&platform.Rule{
|
||||
Name: "bot-only",
|
||||
Allow: []string{"im/**"},
|
||||
MaxRisk: platform.RiskRead,
|
||||
Identities: []platform.Identity{platform.IdentityBot},
|
||||
}).
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// denylistPlugin registers a Restrict rule that allows the docs/** domain
|
||||
// but explicitly denies docs/+search (a real read-risk leaf, see
|
||||
// shortcuts/doc/docs_search.go). Deny has priority over Allow, so the
|
||||
// command is rejected before MaxRisk is even consulted.
|
||||
const denylistPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("denylist-restrict", "0.1.0").
|
||||
Restrict(&platform.Rule{
|
||||
Name: "deny-search",
|
||||
Allow: []string{"docs/**"},
|
||||
Deny: []string{"docs/+search"},
|
||||
MaxRisk: platform.RiskRead,
|
||||
}).
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// multiRulePlugin registers two scope-exclusive Restrict rules (im-only,
|
||||
// docs-only). A command outside both domains (e.g. the top-level "schema"
|
||||
// command, itself read-risk and already proven to hit domain_not_allowed
|
||||
// under a single Allow:["docs/**","im/**"] rule in TestReadonlyDenial) is
|
||||
// rejected by both rules, so cmdpolicy's OR-engine collapses the two
|
||||
// per-rule denials into the aggregate reason_code "no_matching_rule".
|
||||
const multiRulePlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import "github.com/larksuite/cli/extension/platform"
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("multi-rule-restrict", "0.1.0").
|
||||
Restrict(&platform.Rule{
|
||||
Name: "im-only",
|
||||
Allow: []string{"im/**"},
|
||||
MaxRisk: platform.RiskRead,
|
||||
}).
|
||||
Restrict(&platform.Rule{
|
||||
Name: "docs-only",
|
||||
Allow: []string{"docs/**"},
|
||||
MaxRisk: platform.RiskRead,
|
||||
}).
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
// assertReasonCodeEnvelope asserts the VERIFIED envelope shape shared by every
|
||||
// reason_code across this package -- both policy denials (this file) and
|
||||
// install-time failures (install_test.go): exit 2, valid JSON on stderr,
|
||||
// error.type=="validation", error.subtype=="failed_precondition", and
|
||||
// error.hint containing "reason_code <wantReasonCode>". Both paths render
|
||||
// through the SAME cmd/platform_guards.go WithHint(...) family, embedding
|
||||
// reason_code in the hint STRING, not a structured error.detail.reason_code
|
||||
// field (contradicting internal/platform/error.go:34's comment).
|
||||
func assertReasonCodeEnvelope(t *testing.T, res result, wantReasonCode string) {
|
||||
t.Helper()
|
||||
if res.exit != 2 {
|
||||
t.Fatalf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
}
|
||||
if !gjson.Valid(res.stderr) {
|
||||
t.Fatalf("stderr not JSON: %s", res.stderr)
|
||||
}
|
||||
if got := gjson.Get(res.stderr, "error.type").String(); got != "validation" {
|
||||
t.Errorf("error.type=%q want validation", got)
|
||||
}
|
||||
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "failed_precondition" {
|
||||
t.Errorf("error.subtype=%q want failed_precondition", got)
|
||||
}
|
||||
if hint := gjson.Get(res.stderr, "error.hint").String(); !strings.Contains(hint, "reason_code "+wantReasonCode) {
|
||||
t.Errorf("hint=%q want to contain reason_code %s", hint, wantReasonCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIdentityMismatchDenial pins reason_code=identity_mismatch: a bot-only
|
||||
// rule rejects a command whose declared AuthTypes don't include "bot".
|
||||
func TestIdentityMismatchDenial(t *testing.T) {
|
||||
bin := buildFork(t, "identity", identityPlugin)
|
||||
res := run(t, bin, "im", "+messages-search", "--as", "user")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
assertReasonCodeEnvelope(t, res, "identity_mismatch")
|
||||
}
|
||||
|
||||
// TestDenylistDenial pins reason_code=command_denylisted: a Deny glob hit
|
||||
// rejects the command even though it also matches Allow.
|
||||
func TestDenylistDenial(t *testing.T) {
|
||||
bin := buildFork(t, "denylist", denylistPlugin)
|
||||
res := run(t, bin, "docs", "+search")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
assertReasonCodeEnvelope(t, res, "command_denylisted")
|
||||
}
|
||||
|
||||
// TestMultiRuleDenial pins reason_code=no_matching_rule: a command rejected
|
||||
// by every rule in a multi-Restrict() plugin gets the aggregate reason_code,
|
||||
// not either rule's own per-rule reason_code.
|
||||
func TestMultiRuleDenial(t *testing.T) {
|
||||
bin := buildFork(t, "multirule", multiRulePlugin)
|
||||
res := run(t, bin, "schema")
|
||||
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
assertReasonCodeEnvelope(t, res, "no_matching_rule")
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package plugin_e2e
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMain archives HEAD's committed tree once for the whole package before
|
||||
// any fork build runs. It lives here (not in harness.go) because `go test`
|
||||
// only discovers TestMain in a _test.go file — a TestMain defined in a plain
|
||||
// .go file is silently never invoked.
|
||||
// NOTE: exactly one TestMain is allowed per package — do not add another in other _test.go files here.
|
||||
func TestMain(m *testing.M) {
|
||||
root, err := repoRoot()
|
||||
if err != nil {
|
||||
panic("locate repo root: " + err.Error())
|
||||
}
|
||||
baseDir, err = os.MkdirTemp("", "plugin-e2e-")
|
||||
if err != nil {
|
||||
panic("mkdtemp: " + err.Error())
|
||||
}
|
||||
cleanTree = filepath.Join(baseDir, "larkcli-clean")
|
||||
if err := os.MkdirAll(cleanTree, 0o755); err != nil {
|
||||
panic("mkdir clean tree: " + err.Error())
|
||||
}
|
||||
if err := gitArchive(root, cleanTree); err != nil {
|
||||
panic("git archive: " + err.Error())
|
||||
}
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(baseDir)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// noopPlugin registers a plugin that installs nothing observable, proving
|
||||
// the blank-import -> init -> Register -> InstallAll assembly chain links
|
||||
// and the fork boots.
|
||||
const noopPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("smoke", "0.0.1").
|
||||
Observer(platform.After, "noop", platform.All(),
|
||||
func(_ context.Context, _ platform.Invocation) {}).
|
||||
FailOpen().
|
||||
MustBuild())
|
||||
}
|
||||
`
|
||||
|
||||
func TestSmokeForkBoots(t *testing.T) {
|
||||
bin := buildFork(t, "smoke", noopPlugin)
|
||||
res := run(t, bin, "--help")
|
||||
if res.exit != 0 {
|
||||
t.Fatalf("--help exit=%d stderr=%s", res.exit, res.stderr)
|
||||
}
|
||||
if res.stdout == "" {
|
||||
t.Fatalf("--help produced empty stdout")
|
||||
}
|
||||
}
|
||||
@@ -1,466 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar
|
||||
|
||||
// Package sidecar_e2e proves the sidecar auth-proxy wire protocol end-to-end,
|
||||
// offline and secret-free: a real fork binary (built with -tags authsidecar,
|
||||
// exercising the REAL extension/transport/sidecar interceptor) signs a
|
||||
// request with HMAC-SHA256 and routes it to an in-test sidecar, which
|
||||
// verifies the signature using the REAL sidecar.Verify / sidecar.CanonicalRequest
|
||||
// from github.com/larksuite/cli/sidecar, injects a synthetic token, and
|
||||
// forwards to an in-test mock upstream.
|
||||
//
|
||||
// DEVIATION FROM THE ORIGINAL PLAN: the plan called for driving the real
|
||||
// sidecar/server-demo binary (built with -tags authsidecar_demo) as the
|
||||
// middle process. That is infeasible for an OFFLINE test, for three
|
||||
// independent reasons, all verified in source:
|
||||
//
|
||||
// 1. sidecar/server-demo/handler.go:171 resolves a REAL token via
|
||||
// h.cred.ResolveToken(...), which errors out unless the machine has run
|
||||
// `lark-cli auth login` — there is no way to make it return a token
|
||||
// without live credentials.
|
||||
// 2. sidecar/server-demo/main.go builds handler.allowedHosts from
|
||||
// core.ResolveEndpoints(BrandFeishu/BrandLark) only — real feishu/lark
|
||||
// hosts. An in-test mock (127.0.0.1:<port>) is never in that allowlist
|
||||
// and would be rejected with 403 (handler.go step 4).
|
||||
// 3. sidecar/server-demo/handler.go:184 pins the forward scheme to
|
||||
// "https://" + targetHost, ignoring the client-supplied scheme. It can
|
||||
// never be redirected to an http:// mock.
|
||||
//
|
||||
// server-demo's verify+inject logic is ALREADY covered by
|
||||
// `go test -tags authsidecar_demo ./sidecar/server-demo/` (see the
|
||||
// sidecar-test Makefile target, item 3) — that is unit-level coverage of the
|
||||
// same code paths this file would otherwise exercise via a real subprocess.
|
||||
//
|
||||
// So instead, this test builds its OWN in-test sidecar (an httptest.Server)
|
||||
// that mirrors server-demo/handler.go's verify+inject steps 0-8 exactly,
|
||||
// using the real protocol package (sidecar.Verify, sidecar.CanonicalRequest,
|
||||
// sidecar.BodySHA256, the Header* / Sentinel* / Identity* constants) — the
|
||||
// same symbols server-demo itself uses. This is the standard shape for this
|
||||
// kind of test: one real external process (the fork binary, compiled with
|
||||
// the production interceptor code) plus two in-process httptest.Server
|
||||
// stand-ins (sidecar, upstream). It proves the real wire protocol end-to-end
|
||||
// without requiring live credentials, real feishu/lark hosts, or TLS.
|
||||
//
|
||||
// Every key/token/app-id here is an obviously-synthetic placeholder; nothing
|
||||
// in this file can authenticate against anything real.
|
||||
package sidecar_e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
// Synthetic, obviously-fake fixtures. None of these are real secrets.
|
||||
const (
|
||||
testProxyKey = "test-proxy-key-not-a-real-secret-000000000000"
|
||||
testAppID = "cli_test_app_not_real"
|
||||
injectedToken = "fake-injected-token-not-real"
|
||||
)
|
||||
|
||||
// TestSidecarHMACRoundTrip drives the whole wire protocol as three named
|
||||
// steps so the flow is readable at a glance; each step's mechanics live in a
|
||||
// dedicated helper below.
|
||||
func TestSidecarHMACRoundTrip(t *testing.T) {
|
||||
// Two in-process stand-ins: the mock upstream (for open.feishu.cn) and the
|
||||
// in-test sidecar (server-demo's verify+inject, via the real protocol pkg).
|
||||
upstream := startMockUpstream(t)
|
||||
sc := startInTestSidecar(t, []byte(testProxyKey), upstream.URL)
|
||||
|
||||
// One real external process: lark-cli built with -tags authsidecar, run
|
||||
// fully offline against the in-test sidecar.
|
||||
bin := buildAuthsidecarFork(t)
|
||||
runFork(t, bin, sc.URL)
|
||||
|
||||
// Assert the three properties of a correct round trip.
|
||||
assertInterceptorSigned(t, sc) // (a)+(c) fork -> sidecar
|
||||
assertInjectedTokenReachedUpstream(t, upstream) // (b) sidecar -> upstream
|
||||
}
|
||||
|
||||
// --- request capture -------------------------------------------------------
|
||||
|
||||
// capturedRequest snapshots the parts of an *http.Request that matter for
|
||||
// assertions, taken before the request (and its body reader) is consumed or
|
||||
// goes out of scope.
|
||||
type capturedRequest struct {
|
||||
method string
|
||||
path string
|
||||
headers http.Header
|
||||
body []byte
|
||||
}
|
||||
|
||||
// requestSink stores the request a stub server saw, guarded so the httptest
|
||||
// handler goroutine and the test goroutine can hand it over safely.
|
||||
type requestSink struct {
|
||||
mu sync.Mutex
|
||||
req *capturedRequest
|
||||
}
|
||||
|
||||
func (s *requestSink) capture(r *http.Request, body []byte) {
|
||||
snap := capturedRequest{
|
||||
method: r.Method,
|
||||
path: r.URL.RequestURI(),
|
||||
headers: r.Header.Clone(),
|
||||
body: body,
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.req = &snap
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *requestSink) get() *capturedRequest {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.req
|
||||
}
|
||||
|
||||
// --- mock upstream (stands in for open.feishu.cn) --------------------------
|
||||
|
||||
type mockUpstream struct {
|
||||
*httptest.Server
|
||||
sink requestSink
|
||||
}
|
||||
|
||||
func startMockUpstream(t *testing.T) *mockUpstream {
|
||||
t.Helper()
|
||||
m := &mockUpstream{}
|
||||
m.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
m.sink.capture(r, body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"code":0,"msg":"success","data":{"document":{"content":"mock content"}}}`))
|
||||
}))
|
||||
t.Cleanup(m.Close)
|
||||
return m
|
||||
}
|
||||
|
||||
// --- in-test sidecar (mirrors server-demo/handler.go verify+inject) --------
|
||||
|
||||
type inTestSidecar struct {
|
||||
*httptest.Server
|
||||
key []byte
|
||||
upstreamURL string
|
||||
sink requestSink
|
||||
|
||||
mu sync.Mutex // guards verifyRan/verifyErr
|
||||
verifyRan bool
|
||||
verifyErr error
|
||||
}
|
||||
|
||||
func startInTestSidecar(t *testing.T, key []byte, upstreamURL string) *inTestSidecar {
|
||||
t.Helper()
|
||||
s := &inTestSidecar{key: key, upstreamURL: upstreamURL}
|
||||
s.Server = httptest.NewServer(http.HandlerFunc(s.handle))
|
||||
t.Cleanup(s.Close)
|
||||
return s
|
||||
}
|
||||
|
||||
// handle is the request flow: capture -> verify (steps 0-4) -> inject+forward.
|
||||
func (s *inTestSidecar) handle(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
s.sink.capture(r, body)
|
||||
|
||||
authHeader, ok := s.verifyProxyRequest(w, r, body)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.forwardWithInjectedToken(w, r, body, authHeader)
|
||||
}
|
||||
|
||||
// verifyProxyRequest mirrors server-demo/handler.go steps 0-4: protocol
|
||||
// version, body SHA256, target validation, and HMAC signature verification.
|
||||
// It records whether verification ran and its result (for assertions) and
|
||||
// returns the auth header the client committed to. On any failure it writes
|
||||
// the HTTP error and returns ok=false.
|
||||
func (s *inTestSidecar) verifyProxyRequest(w http.ResponseWriter, r *http.Request, body []byte) (authHeader string, ok bool) {
|
||||
// Step 0: protocol version.
|
||||
version := r.Header.Get(sidecar.HeaderProxyVersion)
|
||||
if version != sidecar.ProtocolV1 {
|
||||
http.Error(w, "unsupported "+sidecar.HeaderProxyVersion+": "+version, http.StatusBadRequest)
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Step 1-2: timestamp + body SHA256.
|
||||
ts := r.Header.Get(sidecar.HeaderProxyTimestamp)
|
||||
claimedSHA := r.Header.Get(sidecar.HeaderBodySHA256)
|
||||
if claimedSHA == "" || claimedSHA != sidecar.BodySHA256(body) {
|
||||
http.Error(w, "body SHA256 mismatch", http.StatusBadRequest)
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Step 3: target host, identity, auth-header (all covered by the sig).
|
||||
targetHost, perr := parseTargetHost(r.Header.Get(sidecar.HeaderProxyTarget))
|
||||
if perr != nil {
|
||||
http.Error(w, "invalid "+sidecar.HeaderProxyTarget+": "+perr.Error(), http.StatusForbidden)
|
||||
return "", false
|
||||
}
|
||||
identity := r.Header.Get(sidecar.HeaderProxyIdentity)
|
||||
authHeader = r.Header.Get(sidecar.HeaderProxyAuthHeader)
|
||||
|
||||
// Step 4: verify HMAC signature over the canonical request.
|
||||
err := sidecar.Verify(s.key, sidecar.CanonicalRequest{
|
||||
Version: version,
|
||||
Method: r.Method,
|
||||
Host: targetHost,
|
||||
PathAndQuery: r.URL.RequestURI(),
|
||||
BodySHA256: claimedSHA,
|
||||
Timestamp: ts,
|
||||
Identity: identity,
|
||||
AuthHeader: authHeader,
|
||||
}, r.Header.Get(sidecar.HeaderProxySignature))
|
||||
s.mu.Lock()
|
||||
s.verifyRan = true
|
||||
s.verifyErr = err
|
||||
s.mu.Unlock()
|
||||
if err != nil {
|
||||
http.Error(w, "HMAC verification failed: "+err.Error(), http.StatusUnauthorized)
|
||||
return "", false
|
||||
}
|
||||
return authHeader, true
|
||||
}
|
||||
|
||||
// forwardWithInjectedToken mirrors server-demo's inject+forward. Unlike
|
||||
// server-demo (which forwards to "https://"+targetHost), this test forwards to
|
||||
// the in-test MOCK's URL — proving the sidecar's inject step without needing a
|
||||
// real upstream or a route to targetHost. It strips any client-supplied auth
|
||||
// headers first (the sidecar is the sole source of auth material), injects the
|
||||
// synthetic token into the committed header, and relays the response back.
|
||||
func (s *inTestSidecar) forwardWithInjectedToken(w http.ResponseWriter, r *http.Request, body []byte, authHeader string) {
|
||||
freq, err := http.NewRequest(r.Method, s.upstreamURL+r.URL.RequestURI(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
http.Error(w, "failed to build forward request", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for k, vs := range r.Header {
|
||||
if isProxyHeader(k) {
|
||||
continue
|
||||
}
|
||||
for _, v := range vs {
|
||||
freq.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
freq.Header.Del("Authorization")
|
||||
freq.Header.Del(sidecar.HeaderMCPUAT)
|
||||
freq.Header.Del(sidecar.HeaderMCPTAT)
|
||||
|
||||
if authHeader == "Authorization" {
|
||||
freq.Header.Set("Authorization", "Bearer "+injectedToken)
|
||||
} else {
|
||||
freq.Header.Set(authHeader, injectedToken)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(freq)
|
||||
if err != nil {
|
||||
http.Error(w, "forward failed: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
for k, vs := range resp.Header {
|
||||
for _, v := range vs {
|
||||
w.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = w.Write(respBody)
|
||||
}
|
||||
|
||||
// verifyResult reports whether step 4 ran and, if so, its error.
|
||||
func (s *inTestSidecar) verifyResult() (ran bool, err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.verifyRan, s.verifyErr
|
||||
}
|
||||
|
||||
// isProxyHeader reports whether name is one of the sidecar wire-protocol
|
||||
// headers that must not be copied through to the forwarded (mock upstream)
|
||||
// request. Mirrors sidecar/server-demo/handler.go's isProxyHeader.
|
||||
func isProxyHeader(name string) bool {
|
||||
switch http.CanonicalHeaderKey(name) {
|
||||
case http.CanonicalHeaderKey(sidecar.HeaderProxyVersion),
|
||||
http.CanonicalHeaderKey(sidecar.HeaderProxyTarget),
|
||||
http.CanonicalHeaderKey(sidecar.HeaderProxyIdentity),
|
||||
http.CanonicalHeaderKey(sidecar.HeaderProxySignature),
|
||||
http.CanonicalHeaderKey(sidecar.HeaderProxyTimestamp),
|
||||
http.CanonicalHeaderKey(sidecar.HeaderBodySHA256),
|
||||
http.CanonicalHeaderKey(sidecar.HeaderProxyAuthHeader):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseTargetHost validates X-Lark-Proxy-Target and returns its host.
|
||||
// Mirrors sidecar/server-demo/handler.go's parseTarget: the header must be
|
||||
// "https://<host>" with no path, query, fragment, or userinfo. Only the host
|
||||
// is used, both as HMAC signing input and to record what the fork believed
|
||||
// its real destination was — the actual forward in this test always goes to
|
||||
// the in-test mock, never to this host.
|
||||
func parseTargetHost(target string) (string, error) {
|
||||
u, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse: %w", err)
|
||||
}
|
||||
if u.Scheme != "https" {
|
||||
return "", fmt.Errorf("scheme must be https, got %q", u.Scheme)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return "", fmt.Errorf("missing host")
|
||||
}
|
||||
if u.User != nil {
|
||||
return "", fmt.Errorf("userinfo not allowed")
|
||||
}
|
||||
if u.Path != "" && u.Path != "/" {
|
||||
return "", fmt.Errorf("path not allowed (got %q)", u.Path)
|
||||
}
|
||||
if u.RawQuery != "" {
|
||||
return "", fmt.Errorf("query not allowed")
|
||||
}
|
||||
if u.Fragment != "" {
|
||||
return "", fmt.Errorf("fragment not allowed")
|
||||
}
|
||||
return u.Host, nil
|
||||
}
|
||||
|
||||
// --- fork build + run ------------------------------------------------------
|
||||
|
||||
// buildAuthsidecarFork builds the REAL lark-cli with -tags authsidecar (the
|
||||
// production interceptor) and returns the binary path.
|
||||
func buildAuthsidecarFork(t *testing.T) string {
|
||||
t.Helper()
|
||||
bin := filepath.Join(t.TempDir(), "forkbin")
|
||||
build := exec.Command("go", "build", "-tags", "authsidecar", "-o", bin, ".")
|
||||
build.Dir = repoRoot(t)
|
||||
if out, err := build.CombinedOutput(); err != nil {
|
||||
t.Fatalf("build fork binary: %v\n%s", err, out)
|
||||
}
|
||||
return bin
|
||||
}
|
||||
|
||||
// runFork runs the fork against the in-test sidecar, fully offline. The fork's
|
||||
// exit status is logged but NOT asserted — this test judges wire behavior
|
||||
// (what reached the sidecar/upstream), not the command's own success.
|
||||
func runFork(t *testing.T, binPath, sidecarURL string) {
|
||||
t.Helper()
|
||||
scURL, err := url.Parse(sidecarURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse sidecar URL: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, binPath, "docs", "+fetch", "--doc", "nonexistent", "--as", "user")
|
||||
cmd.Env = append(os.Environ(),
|
||||
"LARKSUITE_CLI_AUTH_PROXY=http://"+scURL.Host,
|
||||
"LARKSUITE_CLI_PROXY_KEY="+testProxyKey,
|
||||
"LARKSUITE_CLI_APP_ID="+testAppID,
|
||||
"LARKSUITE_CLI_BRAND=feishu",
|
||||
"LARKSUITE_CLI_CONFIG_DIR="+t.TempDir(),
|
||||
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1",
|
||||
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1",
|
||||
)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
runErr := cmd.Run()
|
||||
t.Logf("fork exit error (informational only, not asserted): %v", runErr)
|
||||
t.Logf("fork stdout: %s", stdout.String())
|
||||
t.Logf("fork stderr: %s", stderr.String())
|
||||
}
|
||||
|
||||
// repoRoot resolves the lark-cli module root from the test's working
|
||||
// directory (which `go test` sets to the package dir, tests/sidecar_e2e).
|
||||
func repoRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("resolve repo root: %v", err)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
// --- assertions ------------------------------------------------------------
|
||||
|
||||
// assertInterceptorSigned checks the fork -> sidecar hop (assertions a + c):
|
||||
// the real interceptor ran (all proxy headers present, identity=user), stripped
|
||||
// every real/sentinel auth header before signing, and produced a signature that
|
||||
// verified against the shared key.
|
||||
func assertInterceptorSigned(t *testing.T, sc *inTestSidecar) {
|
||||
t.Helper()
|
||||
got := sc.sink.get()
|
||||
if got == nil {
|
||||
t.Fatal("sidecar never received a request from the fork — interceptor did not route to AUTH_PROXY")
|
||||
}
|
||||
ran, verifyErr := sc.verifyResult()
|
||||
if !ran {
|
||||
t.Fatal("sidecar received a request but never reached HMAC verification (rejected earlier — see handler headers)")
|
||||
}
|
||||
if verifyErr != nil {
|
||||
t.Fatalf("HMAC verification failed on the fork's own signed request: %v", verifyErr)
|
||||
}
|
||||
t.Logf("fork->sidecar headers: %v", got.headers)
|
||||
|
||||
// No real/sentinel auth ever left the fork: the interceptor strips the
|
||||
// sentinel before signing, so this hop must carry no auth header at all.
|
||||
if auth := got.headers.Get("Authorization"); auth != "" {
|
||||
t.Fatalf("fork->sidecar hop leaked an Authorization header (want none, interceptor should have stripped it): %q", auth)
|
||||
}
|
||||
if v := got.headers.Get(sidecar.HeaderMCPUAT); v != "" {
|
||||
t.Fatalf("fork->sidecar hop leaked %s (want none): %q", sidecar.HeaderMCPUAT, v)
|
||||
}
|
||||
if v := got.headers.Get(sidecar.HeaderMCPTAT); v != "" {
|
||||
t.Fatalf("fork->sidecar hop leaked %s (want none): %q", sidecar.HeaderMCPTAT, v)
|
||||
}
|
||||
|
||||
// Proxy headers must be present (proves the interceptor actually ran).
|
||||
for _, h := range []string{
|
||||
sidecar.HeaderProxyVersion, sidecar.HeaderProxyTarget, sidecar.HeaderProxyIdentity,
|
||||
sidecar.HeaderProxySignature, sidecar.HeaderProxyTimestamp, sidecar.HeaderBodySHA256,
|
||||
sidecar.HeaderProxyAuthHeader,
|
||||
} {
|
||||
if got.headers.Get(h) == "" {
|
||||
t.Fatalf("fork->sidecar hop missing required proxy header %s", h)
|
||||
}
|
||||
}
|
||||
if id := got.headers.Get(sidecar.HeaderProxyIdentity); id != sidecar.IdentityUser {
|
||||
t.Fatalf("fork->sidecar identity = %q, want %q", id, sidecar.IdentityUser)
|
||||
}
|
||||
}
|
||||
|
||||
// assertInjectedTokenReachedUpstream checks the sidecar -> upstream hop
|
||||
// (assertion b): the mock saw exactly the sidecar-injected synthetic token,
|
||||
// never a sentinel or a real one — proving injection actually happened.
|
||||
func assertInjectedTokenReachedUpstream(t *testing.T, up *mockUpstream) {
|
||||
t.Helper()
|
||||
got := up.sink.get()
|
||||
if got == nil {
|
||||
t.Fatal("mock upstream never received a forwarded request — sidecar did not forward after verification")
|
||||
}
|
||||
t.Logf("sidecar->mock headers: %v", got.headers)
|
||||
|
||||
wantAuth := "Bearer " + injectedToken
|
||||
gotAuth := got.headers.Get("Authorization")
|
||||
if gotAuth != wantAuth {
|
||||
t.Fatalf("mock upstream Authorization = %q, want %q", gotAuth, wantAuth)
|
||||
}
|
||||
// Belt-and-suspenders: the value the mock saw must not be either sentinel,
|
||||
// proving the only token that ever reached "upstream" was the injected one.
|
||||
if gotAuth == "Bearer "+sidecar.SentinelUAT || gotAuth == "Bearer "+sidecar.SentinelTAT {
|
||||
t.Fatalf("mock upstream received a sentinel token instead of the injected one: %q", gotAuth)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user