mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56ed529c1b | ||
|
|
f67f569e76 | ||
|
|
f930d9c52f | ||
|
|
7c3d5b31d5 | ||
|
|
bf537f8d9c | ||
|
|
10caeb5788 | ||
|
|
6a4dd8dc1b | ||
|
|
1f3d9e0420 | ||
|
|
6692300468 | ||
|
|
7baba213bc | ||
|
|
725a62879b | ||
|
|
112dd5f6b2 | ||
|
|
0f96bdf5e8 | ||
|
|
102ee51914 | ||
|
|
79f43dc337 | ||
|
|
f231031041 | ||
|
|
f68a41163e | ||
|
|
eda2b9cd85 | ||
|
|
a703202ef8 | ||
|
|
eb8b542f42 | ||
|
|
d4c051d211 | ||
|
|
5621d2e555 | ||
|
|
17698d5c6a | ||
|
|
70c72a2c02 | ||
|
|
d4e83df22c | ||
|
|
4c51a9874d | ||
|
|
6463ab13c9 | ||
|
|
c4851a5c45 |
16
.github/pull_request_template.md
vendored
Normal file
16
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
## Summary
|
||||
<!-- Briefly describe the motivation and scope of this change in 1-3 sentences. -->
|
||||
|
||||
## Changes
|
||||
<!-- List the main changes in this PR. -->
|
||||
- Change 1
|
||||
- Change 2
|
||||
|
||||
## Test Plan
|
||||
<!-- Describe how this change was verified. -->
|
||||
- [ ] Unit tests pass
|
||||
- [ ] Manual local verification confirms the `lark xxx` command works as expected
|
||||
|
||||
## Related Issues
|
||||
<!-- Link related issues. Use Closes/Fixes to close them automatically. -->
|
||||
- None
|
||||
28
.github/workflows/gitleaks.yml
vendored
Normal file
28
.github/workflows/gitleaks.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
name: Gitleaks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
gitleaks:
|
||||
# Forked pull_request runs do not receive repository/org secrets except GITHUB_TOKEN.
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
|
||||
env:
|
||||
# GITHUB_TOKEN is provided automatically by GitHub Actions.
|
||||
# GITLEAKS_KEY must be configured as a repository secret.
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_KEY }}
|
||||
149
.github/workflows/pkg-pr-new-comment.yml
vendored
Normal file
149
.github/workflows/pkg-pr-new-comment.yml
vendored
Normal file
@@ -0,0 +1,149 @@
|
||||
name: PR Preview Package Comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["PR Preview Package"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check comment payload artifact
|
||||
id: payload
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
script: |
|
||||
const runId = context.payload.workflow_run?.id;
|
||||
const { data } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: runId,
|
||||
per_page: 100,
|
||||
});
|
||||
const found = Boolean(
|
||||
data.artifacts?.some((artifact) => artifact.name === "pkg-pr-new-comment-payload")
|
||||
);
|
||||
core.setOutput("found", found ? "true" : "false");
|
||||
if (!found) {
|
||||
core.notice("No comment payload artifact found for this run; skipping comment.");
|
||||
}
|
||||
|
||||
- name: Download comment payload
|
||||
if: steps.payload.outputs.found == 'true'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: pkg-pr-new-comment-payload
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Comment install command
|
||||
if: steps.payload.outputs.found == 'true'
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
script: |
|
||||
const fs = require("fs");
|
||||
const payload = JSON.parse(fs.readFileSync("pkg-pr-new-comment-payload.json", "utf8"));
|
||||
const url = payload?.url;
|
||||
const payloadPr = payload?.pr;
|
||||
const sourceRepo = payload?.sourceRepo;
|
||||
const sourceBranch = payload?.sourceBranch;
|
||||
if (!Number.isInteger(payloadPr)) {
|
||||
throw new Error(`Invalid PR number in artifact payload: ${payloadPr}`);
|
||||
}
|
||||
if (payloadPr <= 0) {
|
||||
throw new Error(`Invalid PR number in artifact payload: ${payloadPr}`);
|
||||
}
|
||||
const issueNumber = payloadPr;
|
||||
const runPrNumber = context.payload.workflow_run?.pull_requests?.[0]?.number;
|
||||
if (Number.isInteger(runPrNumber) && runPrNumber !== issueNumber) {
|
||||
throw new Error(
|
||||
`PR number mismatch between workflow_run (${runPrNumber}) and artifact payload (${issueNumber})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof url !== "string" || url.trim() !== url || /[\u0000-\u001F\u007F]/.test(url)) {
|
||||
throw new Error(`Invalid package URL in payload: ${url}`);
|
||||
}
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(url);
|
||||
} catch {
|
||||
throw new Error(`Invalid package URL in payload: ${url}`);
|
||||
}
|
||||
if (parsedUrl.protocol !== "https:" || parsedUrl.hostname !== "pkg.pr.new") {
|
||||
throw new Error(`Invalid package URL in payload: ${url}`);
|
||||
}
|
||||
|
||||
const safeRepoPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
||||
const safeBranchPattern = /^[A-Za-z0-9._\/-]+$/;
|
||||
const hasSkillSource =
|
||||
typeof sourceRepo === "string" &&
|
||||
typeof sourceBranch === "string" &&
|
||||
safeRepoPattern.test(sourceRepo) &&
|
||||
safeBranchPattern.test(sourceBranch);
|
||||
const skillSection = hasSkillSource
|
||||
? [
|
||||
"",
|
||||
"### 🧩 Skill update",
|
||||
"",
|
||||
"```bash",
|
||||
`npx skills add ${sourceRepo}#${sourceBranch} -y -g`,
|
||||
"```",
|
||||
]
|
||||
: [
|
||||
"",
|
||||
"### 🧩 Skill update",
|
||||
"",
|
||||
"_Unavailable for this PR because source repo/branch metadata is missing._",
|
||||
];
|
||||
|
||||
const body = [
|
||||
"<!-- pkg-pr-new-install-guide -->",
|
||||
"## 🚀 PR Preview Install Guide",
|
||||
"",
|
||||
"### 🧰 CLI update",
|
||||
"",
|
||||
"```bash",
|
||||
`npm i -g ${url}`,
|
||||
"```",
|
||||
...skillSection,
|
||||
].join("\n");
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const existing = comments.find((comment) =>
|
||||
comment.user?.login === "github-actions[bot]" &&
|
||||
typeof comment.body === "string" &&
|
||||
comment.body.includes("<!-- pkg-pr-new-install-guide -->")
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
body,
|
||||
});
|
||||
}
|
||||
86
.github/workflows/pkg-pr-new.yml
vendored
86
.github/workflows/pkg-pr-new.yml
vendored
@@ -7,7 +7,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
@@ -31,51 +30,42 @@ jobs:
|
||||
- name: Publish to pkg.pr.new
|
||||
run: npx pkg-pr-new publish --no-compact --json output.json --comment=off ./.pkg-pr-new
|
||||
|
||||
- name: Comment install command
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
- name: Build comment payload
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
SOURCE_REPO: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
SOURCE_BRANCH: ${{ github.event.pull_request.head.ref }}
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require("fs");
|
||||
|
||||
const output = JSON.parse(fs.readFileSync("output.json", "utf8"));
|
||||
const url = output?.packages?.[0]?.url;
|
||||
if (!url) throw new Error("No package URL found in output.json");
|
||||
if (!url.startsWith("https://pkg.pr.new/")) {
|
||||
throw new Error(`Unexpected package URL: ${url}`);
|
||||
}
|
||||
|
||||
const pr = Number(process.env.PR_NUMBER);
|
||||
if (!Number.isInteger(pr) || pr <= 0) {
|
||||
throw new Error(`Invalid PR_NUMBER: ${process.env.PR_NUMBER}`);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
pr,
|
||||
url,
|
||||
sourceRepo: process.env.SOURCE_REPO || "",
|
||||
sourceBranch: process.env.SOURCE_BRANCH || "",
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
"pkg-pr-new-comment-payload.json",
|
||||
JSON.stringify(payload),
|
||||
);
|
||||
NODE
|
||||
|
||||
- name: Upload comment payload
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
script: |
|
||||
const fs = require("fs");
|
||||
const output = JSON.parse(fs.readFileSync("output.json", "utf8"));
|
||||
const url = output?.packages?.[0]?.url;
|
||||
if (!url) {
|
||||
throw new Error("No package URL found in output.json");
|
||||
}
|
||||
|
||||
const body = [
|
||||
"Install this PR change globally:",
|
||||
"",
|
||||
"```bash",
|
||||
`npm i -g ${url}`,
|
||||
"```",
|
||||
].join("\n");
|
||||
const issueNumber = context.issue.number;
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const existing = comments.find((comment) =>
|
||||
comment.user?.login === "github-actions[bot]" &&
|
||||
typeof comment.body === "string" &&
|
||||
comment.body.startsWith("Install this PR change globally:")
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
body,
|
||||
});
|
||||
}
|
||||
name: pkg-pr-new-comment-payload
|
||||
path: pkg-pr-new-comment-payload.json
|
||||
|
||||
31
.github/workflows/pr-labels-test.yml
vendored
Normal file
31
.github/workflows/pr-labels-test.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: Test PR Label Logic
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "scripts/pr-labels/**"
|
||||
- ".github/workflows/pr-labels-test.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "scripts/pr-labels/**"
|
||||
- ".github/workflows/pr-labels-test.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test-pr-labels:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Run PR label regression tests
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: node scripts/pr-labels/test.js
|
||||
43
.github/workflows/pr-labels.yml
vendored
Normal file
43
.github/workflows/pr-labels.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
name: PR Labels
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
# NOTE: This event runs with base-branch code and write permissions.
|
||||
# Do NOT add `ref: github.event.pull_request.head.sha` to the checkout step,
|
||||
# as that would execute untrusted PR code with elevated access.
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- reopened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
sync-pr-labels:
|
||||
if: ${{ github.event.pull_request.state == 'open' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Sync managed PR labels
|
||||
id: sync_pr_labels
|
||||
# Labeling is best-effort and must not block PR merges.
|
||||
continue-on-error: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: node scripts/pr-labels/index.js
|
||||
|
||||
- name: Warn when label sync fails
|
||||
if: ${{ always() && steps.sync_pr_labels.outcome == 'failure' }}
|
||||
run: |
|
||||
echo "::warning::PR label sync failed; labels may be stale."
|
||||
echo "⚠️ PR label sync failed; labels may be stale." >> "$GITHUB_STEP_SUMMARY"
|
||||
32
.github/workflows/skill-format-check.yml
vendored
Normal file
32
.github/workflows/skill-format-check.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: Skill Format Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "skills/**"
|
||||
- "scripts/skill-format-check/**"
|
||||
- ".github/workflows/skill-format-check.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "skills/**"
|
||||
- "scripts/skill-format-check/**"
|
||||
- ".github/workflows/skill-format-check.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-format:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Run Skill Format Check
|
||||
run: node scripts/skill-format-check/index.js
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -30,3 +30,7 @@ test_scripts/
|
||||
tests/mail/reports/
|
||||
|
||||
/log/
|
||||
|
||||
# Generated / test artifacts
|
||||
internal/registry/meta_data.json
|
||||
cmd/api/download.bin
|
||||
|
||||
16
.gitleaks.toml
Normal file
16
.gitleaks.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
title = "lark-cli gitleaks config"
|
||||
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
[[rules]]
|
||||
id = "lark-bot-app-id"
|
||||
description = "Detect Lark bot app ids"
|
||||
regex = '''\bcli_[a-z0-9]{16}\b'''
|
||||
keywords = ["cli_"]
|
||||
|
||||
[[rules]]
|
||||
id = "lark-session-token"
|
||||
description = "Detect Lark session tokens"
|
||||
regex = '''\bXN0YXJ0-[A-Za-z0-9_-]+-WVuZA\b'''
|
||||
keywords = ["XN0YXJ0-", "-WVuZA"]
|
||||
33
AGENTS.md
Normal file
33
AGENTS.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# AGENTS.md
|
||||
Concise maintainer/developer guide for building, testing, and opening high-quality PRs in this repo.
|
||||
|
||||
## Goal (pick one per PR)
|
||||
- Make CLI better: improve UX, error messages, help text, flags, and output clarity.
|
||||
- Improve reliability: fix bugs, edge cases, and regressions with tests.
|
||||
- Improve developer velocity: simplify code paths, reduce complexity, keep behavior explicit.
|
||||
- Improve quality gates: strengthen tests/lint/checks without adding heavy process.
|
||||
|
||||
## Fast Dev Loop
|
||||
1. `make build` (runs `python3 scripts/fetch_meta.py` first)
|
||||
2. `make unit-test` (required before PR)
|
||||
3. Run changed command(s) manually via `./lark-cli ...`
|
||||
|
||||
## Pre-PR Checks (match CI gates)
|
||||
1. `make unit-test`
|
||||
2. `go mod tidy` (must not change `go.mod`/`go.sum`)
|
||||
3. `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main`
|
||||
4. If dependencies changed: `go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown`
|
||||
5. Optional full local suite: `make test` (vet + unit + integration)
|
||||
|
||||
## Test/Check Commands
|
||||
- Unit: `make unit-test`
|
||||
- Integration: `make integration-test`
|
||||
- Full: `make test`
|
||||
- Vet only: `make vet`
|
||||
- Coverage (local): `go test -race -coverprofile=coverage.txt -covermode=atomic ./...`
|
||||
|
||||
## Commit/PR Rules
|
||||
- Use Conventional Commits in English: `feat: ...`, `fix: ...`, `docs: ...`, `ci: ...`, `test: ...`, `chore: ...`, `refactor: ...`
|
||||
- Keep PR title in the same Conventional Commit format (squash merge keeps it).
|
||||
- Before opening a real PR, draft/fill description from `.github/pull_request_template.md` and ensure Summary/Changes/Test Plan are complete.
|
||||
- Never commit secrets/tokens/internal sensitive data.
|
||||
51
CHANGELOG.md
51
CHANGELOG.md
@@ -2,6 +2,55 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.3] - 2026-04-02
|
||||
|
||||
### Features
|
||||
|
||||
- Add `--jq` flag for filtering JSON output (#211)
|
||||
- Add `+download` shortcut for minutes media download (#101)
|
||||
- Add drive import, export, move, and task result shortcuts (#194)
|
||||
- Support im message send/reply with uat (#180)
|
||||
- Add approve domain (#217)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **mail**: Use in-memory keyring in mail scope tests to avoid macOS keychain popups (#212)
|
||||
- **mail**: On-demand scope checks and watch event filtering (#198)
|
||||
- Use curl for binary download to support proxy and add npmmirror fallback (#226)
|
||||
- Normalize escaped sheet range separators (#207)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **mail**: Clarify JSON output is directly usable without extra encoding (#228)
|
||||
- Clarify docs search query usage (#221)
|
||||
|
||||
### CI
|
||||
|
||||
- Add gitleaks scanning workflow and custom rules (#142)
|
||||
|
||||
## [v1.0.2] - 2026-04-01
|
||||
|
||||
### Features
|
||||
|
||||
- Improve OS keychain/DPAPI access error handling for sandbox environments (#173)
|
||||
- **mail**: Auto-resolve local image paths in draft body HTML (#139)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Correct URL formatting in login `--no-wait` output (#169)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Add concise AGENTS development guide (#178)
|
||||
|
||||
### CI
|
||||
|
||||
- Refine PR business area labels and introduce skill format check (#148)
|
||||
|
||||
### Chore
|
||||
|
||||
- Add pull request template (#176)
|
||||
|
||||
## [v1.0.1] - 2026-03-31
|
||||
|
||||
### Features
|
||||
@@ -87,5 +136,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.3]: https://github.com/larksuite/cli/releases/tag/v1.0.3
|
||||
[v1.0.2]: https://github.com/larksuite/cli/releases/tag/v1.0.2
|
||||
[v1.0.1]: https://github.com/larksuite/cli/releases/tag/v1.0.1
|
||||
[v1.0.0]: https://github.com/larksuite/cli/releases/tag/v1.0.0
|
||||
|
||||
72
README.md
72
README.md
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 19 AI Agent [Skills](./skills/).
|
||||
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 20 AI Agent [Skills](./skills/).
|
||||
|
||||
[Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing)
|
||||
|
||||
## Why lark-cli?
|
||||
|
||||
- **Agent-Native Design** — 19 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 11 business domains, 200+ curated commands, 19 AI Agent [Skills](./skills/)
|
||||
- **Agent-Native Design** — 20 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 12 business domains, 200+ curated commands, 20 AI Agent [Skills](./skills/)
|
||||
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output to maximize Agent call success rates
|
||||
- **Open Source, Zero Barriers** — MIT license, ready to use, just `npm install`
|
||||
- **Up and Running in 3 Minutes** — One-click app creation, interactive login, from install to first API call in just 3 steps
|
||||
@@ -22,19 +22,20 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
|
||||
## Features
|
||||
|
||||
| Category | Capabilities |
|
||||
| ------------- | ----------------------------------------------------------------------------------- |
|
||||
| 📅 Calendar | View agenda, create events, invite attendees, check free/busy status, time suggestions |
|
||||
| 💬 Messenger | Send/reply messages, create and manage group chats, view chat history & threads, search messages, download media |
|
||||
| 📄 Docs | Create, read, update, and search documents, read/write media & whiteboards |
|
||||
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
|
||||
| Category | Capabilities |
|
||||
| ------------- |-----------------------------------------------------------------------------------------------------------------------------------|
|
||||
| 📅 Calendar | View agenda, create events, invite attendees, check free/busy status, time suggestions |
|
||||
| 💬 Messenger | Send/reply messages, create and manage group chats, view chat history & threads, search messages, download media |
|
||||
| 📄 Docs | Create, read, update, and search documents, read/write media & whiteboards |
|
||||
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
|
||||
| 📊 Base | Create and manage tables, fields, records, views, dashboards, workflows, forms, roles & permissions, data aggregation & analytics |
|
||||
| 📈 Sheets | Create, read, write, append, find, and export spreadsheet data |
|
||||
| ✅ Tasks | Create, query, update, and complete tasks; manage task lists, subtasks, comments & reminders |
|
||||
| 📚 Wiki | Create and manage knowledge spaces, nodes, and documents |
|
||||
| 👤 Contact | Search users by name/email/phone, get user profiles |
|
||||
| 📧 Mail | Browse, search, read emails, send, reply, forward, manage drafts, watch new mail |
|
||||
| 🎥 Meetings | Search meeting records, query meeting minutes & recordings |
|
||||
| 📈 Sheets | Create, read, write, append, find, and export spreadsheet data |
|
||||
| ✅ Tasks | Create, query, update, and complete tasks; manage task lists, subtasks, comments & reminders |
|
||||
| 📚 Wiki | Create and manage knowledge spaces, nodes, and documents |
|
||||
| 👤 Contact | Search users by name/email/phone, get user profiles |
|
||||
| 📧 Mail | Browse, search, read emails, send, reply, forward, manage drafts, watch new mail |
|
||||
| 🎥 Meetings | Search meeting records, query meeting minutes & recordings |
|
||||
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
|
||||
|
||||
## Installation & Quick Start
|
||||
|
||||
@@ -127,27 +128,28 @@ lark-cli auth status
|
||||
|
||||
## Agent Skills
|
||||
|
||||
| Skill | Description |
|
||||
| ------------------------------- | ------------------------------------------------------------------------------------- |
|
||||
| Skill | Description |
|
||||
| ------------------------------- |----------------------------------------------------------------------------------------------------------------|
|
||||
| `lark-shared` | App config, auth login, identity switching, scope management, security rules (auto-loaded by all other skills) |
|
||||
| `lark-calendar` | Calendar events, agenda view, free/busy queries, time suggestions |
|
||||
| `lark-im` | Send/reply messages, group chat management, message search, upload/download images & files, reactions |
|
||||
| `lark-doc` | Create, read, update, search documents (Markdown-based) |
|
||||
| `lark-drive` | Upload, download files, manage permissions & comments |
|
||||
| `lark-sheets` | Create, read, write, append, find, export spreadsheets |
|
||||
| `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics |
|
||||
| `lark-task` | Tasks, task lists, subtasks, reminders, member assignment |
|
||||
| `lark-mail` | Browse, search, read emails, send, reply, forward, draft management, watch new mail |
|
||||
| `lark-contact` | Search users by name/email/phone, get user profiles |
|
||||
| `lark-wiki` | Knowledge spaces, nodes, documents |
|
||||
| `lark-event` | Real-time event subscriptions (WebSocket), regex routing & agent-friendly format |
|
||||
| `lark-vc` | Search meeting records, query meeting minutes (summary, todos, transcript) |
|
||||
| `lark-whiteboard` | Whiteboard/chart DSL rendering |
|
||||
| `lark-minutes` | Minutes metadata & AI artifacts (summary, todos, chapters) |
|
||||
| `lark-openapi-explorer` | Explore underlying APIs from official docs |
|
||||
| `lark-skill-maker` | Custom skill creation framework |
|
||||
| `lark-workflow-meeting-summary` | Workflow: meeting minutes aggregation & structured report |
|
||||
| `lark-workflow-standup-report` | Workflow: agenda & todo summary |
|
||||
| `lark-calendar` | Calendar events, agenda view, free/busy queries, time suggestions |
|
||||
| `lark-im` | Send/reply messages, group chat management, message search, upload/download images & files, reactions |
|
||||
| `lark-doc` | Create, read, update, search documents (Markdown-based) |
|
||||
| `lark-drive` | Upload, download files, manage permissions & comments |
|
||||
| `lark-sheets` | Create, read, write, append, find, export spreadsheets |
|
||||
| `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics |
|
||||
| `lark-task` | Tasks, task lists, subtasks, reminders, member assignment |
|
||||
| `lark-mail` | Browse, search, read emails, send, reply, forward, draft management, watch new mail |
|
||||
| `lark-contact` | Search users by name/email/phone, get user profiles |
|
||||
| `lark-wiki` | Knowledge spaces, nodes, documents |
|
||||
| `lark-event` | Real-time event subscriptions (WebSocket), regex routing & agent-friendly format |
|
||||
| `lark-vc` | Search meeting records, query meeting minutes (summary, todos, transcript) |
|
||||
| `lark-whiteboard` | Whiteboard/chart DSL rendering |
|
||||
| `lark-minutes` | Minutes metadata & AI artifacts (summary, todos, chapters) |
|
||||
| `lark-openapi-explorer` | Explore underlying APIs from official docs |
|
||||
| `lark-skill-maker` | Custom skill creation framework |
|
||||
| `lark-approval` | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
|
||||
| `lark-workflow-meeting-summary` | Workflow: meeting minutes aggregation & structured report |
|
||||
| `lark-workflow-standup-report` | Workflow: agenda & todo summary |
|
||||
|
||||
## Authentication
|
||||
|
||||
|
||||
70
README.zh.md
70
README.zh.md
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 19 个 AI Agent [Skills](./skills/)。
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 20 个 AI Agent [Skills](./skills/)。
|
||||
|
||||
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
|
||||
|
||||
## 为什么选 lark-cli?
|
||||
|
||||
- **为 Agent 原生设计** — [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 11 大业务域、200+ 精选命令、 19 个 AI Agent [Skills](./skills/)
|
||||
- **覆盖面广** — 12 大业务域、200+ 精选命令、 20 个 AI Agent [Skills](./skills/)
|
||||
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
|
||||
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
|
||||
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
|
||||
@@ -22,19 +22,20 @@
|
||||
|
||||
## 功能
|
||||
|
||||
| 类别 | 能力 |
|
||||
| ------------- | --------------------------------------------------------------------------- |
|
||||
| 📅 日历 | 查看日程、创建日程、邀请参会人、查询忙闲状态、时间建议 |
|
||||
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
|
||||
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
|
||||
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
|
||||
| 类别 | 能力 |
|
||||
| ------------- |--------------------------------------------|
|
||||
| 📅 日历 | 查看日程、创建日程、邀请参会人、查询忙闲状态、时间建议 |
|
||||
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
|
||||
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
|
||||
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
|
||||
| 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 |
|
||||
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
|
||||
| ✅ 任务 | 创建、查询、更新和完成任务;管理任务清单、子任务、评论与提醒 |
|
||||
| 📚 知识库 | 创建和管理知识空间、节点和文档 |
|
||||
| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 |
|
||||
| 📧 邮箱 | 浏览、搜索、阅读邮件,发送、回复、转发邮件,管理草稿,监听新邮件 |
|
||||
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
|
||||
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
|
||||
| ✅ 任务 | 创建、查询、更新和完成任务;管理任务清单、子任务、评论与提醒 |
|
||||
| 📚 知识库 | 创建和管理知识空间、节点和文档 |
|
||||
| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 |
|
||||
| 📧 邮箱 | 浏览、搜索、阅读邮件,发送、回复、转发邮件,管理草稿,监听新邮件 |
|
||||
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
|
||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
|
||||
## 安装与快速开始
|
||||
|
||||
@@ -128,27 +129,28 @@ lark-cli auth status
|
||||
|
||||
## Agent Skills
|
||||
|
||||
| Skill | 说明 |
|
||||
| --------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| Skill | 说明 |
|
||||
| --------------------------------- |-------------------------------------------|
|
||||
| `lark-shared` | 应用配置、认证登录、身份切换、权限管理、安全规则(所有其他 skill 自动加载) |
|
||||
| `lark-calendar` | 日历日程、议程查看、忙闲查询、时间建议 |
|
||||
| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 |
|
||||
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown) |
|
||||
| `lark-drive` | 上传、下载文件,管理权限与评论 |
|
||||
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
|
||||
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |
|
||||
| `lark-task` | 任务、任务清单、子任务、提醒、成员分配 |
|
||||
| `lark-mail` | 浏览、搜索、阅读邮件,发送、回复、转发,草稿管理,监听新邮件 |
|
||||
| `lark-contact` | 按姓名/邮箱/手机号搜索用户,获取用户信息 |
|
||||
| `lark-wiki` | 知识空间、节点、文档 |
|
||||
| `lark-event` | 实时事件订阅(WebSocket),支持正则路由与 Agent 友好格式 |
|
||||
| `lark-vc` | 搜索会议记录、查询会议纪要产物(总结、待办、逐字稿) |
|
||||
| `lark-whiteboard` | 画板/图表 DSL 渲染 |
|
||||
| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节) |
|
||||
| `lark-openapi-explorer` | 从官方文档探索底层 API |
|
||||
| `lark-skill-maker` | 自定义 skill 创建框架 |
|
||||
| `lark-workflow-meeting-summary` | 工作流:会议纪要汇总与结构化报告 |
|
||||
| `lark-workflow-standup-report` | 工作流:日程待办摘要 |
|
||||
| `lark-calendar` | 日历日程、议程查看、忙闲查询、时间建议 |
|
||||
| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 |
|
||||
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown) |
|
||||
| `lark-drive` | 上传、下载文件,管理权限与评论 |
|
||||
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
|
||||
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |
|
||||
| `lark-task` | 任务、任务清单、子任务、提醒、成员分配 |
|
||||
| `lark-mail` | 浏览、搜索、阅读邮件,发送、回复、转发,草稿管理,监听新邮件 |
|
||||
| `lark-contact` | 按姓名/邮箱/手机号搜索用户,获取用户信息 |
|
||||
| `lark-wiki` | 知识空间、节点、文档 |
|
||||
| `lark-event` | 实时事件订阅(WebSocket),支持正则路由与 Agent 友好格式 |
|
||||
| `lark-vc` | 搜索会议记录、查询会议纪要产物(总结、待办、逐字稿) |
|
||||
| `lark-whiteboard` | 画板/图表 DSL 渲染 |
|
||||
| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节) |
|
||||
| `lark-openapi-explorer` | 从官方文档探索底层 API |
|
||||
| `lark-skill-maker` | 自定义 skill 创建框架 |
|
||||
| `lark-approval` | 审批任务查询、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| `lark-workflow-meeting-summary` | 工作流:会议纪要汇总与结构化报告 |
|
||||
| `lark-workflow-standup-report` | 工作流:日程待办摘要 |
|
||||
|
||||
## 认证
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ type APIOptions struct {
|
||||
PageLimit int
|
||||
PageDelay int
|
||||
Format string
|
||||
JqExpr string
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
@@ -96,6 +97,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
|
||||
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
||||
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
||||
|
||||
cmd.ValidArgsFunction = func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
@@ -155,6 +157,9 @@ func apiRun(opts *APIOptions) error {
|
||||
if opts.PageAll && opts.Output != "" {
|
||||
return output.ErrValidation("--output and --page-all are mutually exclusive")
|
||||
}
|
||||
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
request, err := buildAPIRequest(opts)
|
||||
if err != nil {
|
||||
@@ -184,7 +189,7 @@ func apiRun(opts *APIOptions) error {
|
||||
}
|
||||
|
||||
if opts.PageAll {
|
||||
return apiPaginate(opts.Ctx, ac, request, format, out, f.IOStreams.ErrOut,
|
||||
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
|
||||
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay})
|
||||
}
|
||||
|
||||
@@ -195,6 +200,7 @@ func apiRun(opts *APIOptions) error {
|
||||
err = client.HandleResponse(resp, client.ResponseOptions{
|
||||
OutputPath: opts.Output,
|
||||
Format: format,
|
||||
JqExpr: opts.JqExpr,
|
||||
Out: out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
})
|
||||
@@ -210,7 +216,15 @@ func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.Cl
|
||||
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
|
||||
}
|
||||
|
||||
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, out, errOut io.Writer, pagOpts client.PaginationOptions) error {
|
||||
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions) error {
|
||||
// When jq is set, always aggregate all pages then filter.
|
||||
if jqExpr != "" {
|
||||
if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, client.CheckLarkResponse); err != nil {
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
switch format {
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
pf := output.NewPaginatedFormatter(out, format)
|
||||
|
||||
@@ -536,6 +536,179 @@ func TestApiCmd_PageAll_APIError_IsRaw(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JqFlag_Parsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--jq", ".data"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts.JqExpr != ".data" {
|
||||
t.Errorf("expected JqExpr=.data, got %s", gotOpts.JqExpr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "-q", ".data"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts.JqExpr != ".data" {
|
||||
t.Errorf("expected JqExpr=.data, got %s", gotOpts.JqExpr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JqAndOutputConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --jq + --output conflict")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-jq", "expire": 7200,
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/jq",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"name": "Alice"},
|
||||
map[string]interface{}{"name": "Bob"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "Alice") || !strings.Contains(out, "Bob") {
|
||||
t.Errorf("expected jq-filtered names, got: %s", out)
|
||||
}
|
||||
// Should NOT contain the full envelope structure
|
||||
if strings.Contains(out, `"code"`) {
|
||||
t.Errorf("expected jq to filter out envelope, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JqAndFormatConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --jq + --format ndjson conflict")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JqInvalidExpression(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid jq expression")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid jq expression") {
|
||||
t.Errorf("expected 'invalid jq expression' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_WithJq(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pjq", AppSecret: "test-secret-pjq", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-pjq", "expire": 7200,
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "u1"}, map[string]interface{}{"id": "u2"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "u1") || !strings.Contains(out, "u2") {
|
||||
t.Errorf("expected jq-filtered ids, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, `"code"`) {
|
||||
t.Errorf("expected jq to filter out envelope, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_MethodUppercase(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
|
||||
@@ -90,6 +90,7 @@ func completeDomain(toComplete string) []string {
|
||||
return completions
|
||||
}
|
||||
|
||||
// authLoginRun executes the login command logic.
|
||||
func authLoginRun(opts *LoginOptions) error {
|
||||
f := opts.Factory
|
||||
|
||||
@@ -225,26 +226,34 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
|
||||
// --no-wait: return immediately with device code and URL
|
||||
if opts.NoWait {
|
||||
b, _ := json.Marshal(map[string]interface{}{
|
||||
data := map[string]interface{}{
|
||||
"verification_url": authResp.VerificationUriComplete,
|
||||
"device_code": authResp.DeviceCode,
|
||||
"expires_in": authResp.ExpiresIn,
|
||||
"hint": fmt.Sprintf("Show verification_url to user, then immediately execute: lark-cli auth login --device-code %s (blocks until authorized or timeout). Do not instruct the user to run this command themselves.", authResp.DeviceCode),
|
||||
})
|
||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||
}
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(data); err != nil {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "error: failed to write JSON output: %v\n", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 2: Show user code and verification URL
|
||||
if opts.JSON {
|
||||
b, _ := json.Marshal(map[string]interface{}{
|
||||
data := map[string]interface{}{
|
||||
"event": "device_authorization",
|
||||
"verification_uri": authResp.VerificationUri,
|
||||
"verification_uri_complete": authResp.VerificationUriComplete,
|
||||
"user_code": authResp.UserCode,
|
||||
"expires_in": authResp.ExpiresIn,
|
||||
})
|
||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||
}
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(data); err != nil {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "error: failed to write JSON output: %v\n", err)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
|
||||
|
||||
13
cmd/root.go
13
cmd/root.go
@@ -4,6 +4,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -60,6 +61,8 @@ FLAGS:
|
||||
--page-limit <N> max pages to fetch with --page-all (default: 10, 0 for unlimited)
|
||||
--page-delay <MS> delay in ms between pages (default: 200, only with --page-all)
|
||||
-o, --output <path> output file path for binary responses
|
||||
--jq <expr> jq expression to filter JSON output
|
||||
-q <expr> shorthand for --jq
|
||||
--dry-run print request without executing
|
||||
|
||||
AI AGENT SKILLS:
|
||||
@@ -241,12 +244,18 @@ func writeSecurityPolicyError(w io.Writer, spErr *internalauth.SecurityPolicyErr
|
||||
}
|
||||
|
||||
env := map[string]interface{}{"ok": false, "error": errData}
|
||||
b, err := json.MarshalIndent(env, "", " ")
|
||||
|
||||
buffer := &bytes.Buffer{}
|
||||
encoder := json.NewEncoder(buffer)
|
||||
encoder.SetEscapeHTML(false)
|
||||
encoder.SetIndent("", " ")
|
||||
err := encoder.Encode(env)
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintln(w, `{"ok":false,"error":{"type":"internal_error","code":"marshal_error","message":"failed to marshal error"}}`)
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, string(b))
|
||||
fmt.Fprint(w, buffer.String())
|
||||
}
|
||||
|
||||
// installTipsHelpFunc wraps the default help function to append a TIPS section
|
||||
|
||||
@@ -109,6 +109,7 @@ type ServiceMethodOptions struct {
|
||||
PageLimit int
|
||||
PageDelay int
|
||||
Format string
|
||||
JqExpr string
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
@@ -157,6 +158,7 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}
|
||||
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
||||
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
||||
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
@@ -185,6 +187,9 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
if opts.PageAll && opts.Output != "" {
|
||||
return output.ErrValidation("--output and --page-all are mutually exclusive")
|
||||
}
|
||||
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config, err := f.ResolveConfig(opts.As)
|
||||
if err != nil {
|
||||
@@ -223,7 +228,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
checkErr := scopeAwareChecker(scopes, opts.As.IsBot())
|
||||
|
||||
if opts.PageAll {
|
||||
return servicePaginate(opts.Ctx, ac, request, format, out, f.IOStreams.ErrOut,
|
||||
return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
|
||||
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay}, checkErr)
|
||||
}
|
||||
|
||||
@@ -234,6 +239,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
return client.HandleResponse(resp, client.ResponseOptions{
|
||||
OutputPath: opts.Output,
|
||||
Format: format,
|
||||
JqExpr: opts.JqExpr,
|
||||
Out: out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
CheckError: checkErr,
|
||||
@@ -400,7 +406,12 @@ func scopeAwareChecker(scopes []interface{}, isBotMode bool) func(interface{}) e
|
||||
}
|
||||
}
|
||||
|
||||
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}) error) error {
|
||||
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}) error) error {
|
||||
// When jq is set, always aggregate all pages then filter.
|
||||
if jqExpr != "" {
|
||||
return client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, checkErr)
|
||||
}
|
||||
|
||||
switch format {
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
pf := output.NewPaginatedFormatter(out, format)
|
||||
|
||||
@@ -474,6 +474,173 @@ func TestServiceMethod_UnknownFormat_Warning(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── jq flag ──
|
||||
|
||||
func TestNewCmdServiceMethod_JqFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
|
||||
var captured *ServiceMethodOptions
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
|
||||
func(opts *ServiceMethodOptions) error {
|
||||
captured = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--jq", ".data"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if captured == nil {
|
||||
t.Fatal("runF was not called")
|
||||
}
|
||||
if captured.JqExpr != ".data" {
|
||||
t.Errorf("expected JqExpr=.data, got %s", captured.JqExpr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdServiceMethod_JqShortForm(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
|
||||
var captured *ServiceMethodOptions
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
|
||||
func(opts *ServiceMethodOptions) error {
|
||||
captured = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"-q", ".data"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if captured.JqExpr != ".data" {
|
||||
t.Errorf("expected JqExpr=.data, got %s", captured.JqExpr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_JqAndOutputConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--jq", ".data", "--output", "file.bin", "--as", "bot"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --jq + --output conflict")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_JqFilter_AppliesExpression(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(tokenStub())
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"name": "Alice"},
|
||||
map[string]interface{}{"name": "Bob"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot", "--jq", ".data.items[].name"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "Alice") || !strings.Contains(out, "Bob") {
|
||||
t.Errorf("expected jq-filtered names, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, `"code"`) {
|
||||
t.Errorf("expected jq to filter out envelope, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_JqAndFormatConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--jq", ".data", "--format", "ndjson", "--as", "bot"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --jq + --format ndjson conflict")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_JqInvalidExpression(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--jq", "invalid[", "--as", "bot"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid jq expression")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid jq expression") {
|
||||
t.Errorf("expected 'invalid jq expression' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_PageAll_WithJq(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-spjq", AppSecret: "test-secret-spjq", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(tokenStub())
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "s1"}, map[string]interface{}{"id": "s2"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot", "--page-all", "--jq", ".data.items[].id"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "s1") || !strings.Contains(out, "s2") {
|
||||
t.Errorf("expected jq-filtered ids, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, `"code"`) {
|
||||
t.Errorf("expected jq to filter out envelope, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ── scopeAwareChecker ──
|
||||
|
||||
func TestScopeAwareChecker_Success(t *testing.T) {
|
||||
|
||||
2
go.mod
2
go.mod
@@ -7,6 +7,7 @@ require (
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/gofrs/flock v0.8.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/itchyny/gojq v0.12.17
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/smartystreets/goconvey v1.8.1
|
||||
@@ -37,6 +38,7 @@ require (
|
||||
github.com/gopherjs/gopherjs v1.17.2 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/itchyny/timefmt-go v0.1.6 // indirect
|
||||
github.com/jtolds/gls v4.20.0+incompatible // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
|
||||
4
go.sum
4
go.sum
@@ -61,6 +61,10 @@ github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWm
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg=
|
||||
github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY=
|
||||
github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q=
|
||||
github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
|
||||
@@ -39,8 +39,8 @@ func MaskToken(token string) string {
|
||||
|
||||
// GetStoredToken reads the stored UAT for a given (appId, userOpenId) pair.
|
||||
func GetStoredToken(appId, userOpenId string) *StoredUAToken {
|
||||
jsonStr := keychain.Get(keychain.LarkCliService, accountKey(appId, userOpenId))
|
||||
if jsonStr == "" {
|
||||
jsonStr, err := keychain.Get(keychain.LarkCliService, accountKey(appId, userOpenId))
|
||||
if err != nil || jsonStr == "" {
|
||||
return nil
|
||||
}
|
||||
var token StoredUAToken
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
@@ -16,6 +17,22 @@ type PaginationOptions struct {
|
||||
PageDelay int // ms, default 200
|
||||
}
|
||||
|
||||
// PaginateWithJq aggregates all pages, checks for API errors, then applies a jq filter.
|
||||
// If checkErr detects an error, the raw result is printed as JSON before returning the error.
|
||||
func PaginateWithJq(ctx context.Context, ac *APIClient, request RawApiRequest,
|
||||
jqExpr string, out io.Writer, pagOpts PaginationOptions,
|
||||
checkErr func(interface{}) error) error {
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return output.ErrNetwork("API call failed: %v", err)
|
||||
}
|
||||
if apiErr := checkErr(result); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return apiErr
|
||||
}
|
||||
return output.JqFilter(out, result, jqExpr)
|
||||
}
|
||||
|
||||
func mergePagedResults(w io.Writer, results []interface{}) interface{} {
|
||||
if len(results) == 0 {
|
||||
return map[string]interface{}{}
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
type ResponseOptions struct {
|
||||
OutputPath string // --output flag; "" = auto-detect
|
||||
Format output.Format // output format for JSON responses
|
||||
JqExpr string // if set, apply jq filter instead of Format
|
||||
Out io.Writer // stdout
|
||||
ErrOut io.Writer // stderr
|
||||
// CheckError is called on parsed JSON results. Nil defaults to CheckLarkResponse.
|
||||
@@ -62,11 +63,17 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
|
||||
if opts.OutputPath != "" {
|
||||
return saveAndPrint(resp, opts.OutputPath, opts.Out)
|
||||
}
|
||||
if opts.JqExpr != "" {
|
||||
return output.JqFilter(opts.Out, result, opts.JqExpr)
|
||||
}
|
||||
output.FormatValue(opts.Out, result, opts.Format)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Non-JSON (binary) responses.
|
||||
if opts.JqExpr != "" {
|
||||
return output.ErrValidation("--jq requires a JSON response (got Content-Type: %s)", ct)
|
||||
}
|
||||
if opts.OutputPath != "" {
|
||||
return saveAndPrint(resp, opts.OutputPath, opts.Out)
|
||||
}
|
||||
|
||||
@@ -319,6 +319,23 @@ func TestHandleResponse_200TextPlain_SavesFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_BinaryWithJq_RejectsNonJSON(t *testing.T) {
|
||||
resp := newApiResp([]byte("PNG DATA"), map[string]string{"Content-Type": "image/png"})
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{
|
||||
JqExpr: ".data",
|
||||
Out: &out,
|
||||
ErrOut: &errOut,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --jq is used with non-JSON response")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--jq requires a JSON response") {
|
||||
t.Errorf("expected '--jq requires a JSON response' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_403JSON_CheckLarkResponse(t *testing.T) {
|
||||
body := []byte(`{"code":99991400,"msg":"invalid token"}`)
|
||||
resp := newApiRespWithStatus(403, body, map[string]string{"Content-Type": "application/json"})
|
||||
|
||||
@@ -5,11 +5,13 @@ package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
@@ -113,6 +115,12 @@ func RequireConfig(kc keychain.KeychainAccess) (*CliConfig, error) {
|
||||
app := raw.Apps[0]
|
||||
secret, err := ResolveSecretInput(app.AppSecret, kc)
|
||||
if err != nil {
|
||||
// If the error comes from the keychain, it will already be wrapped as an ExitError.
|
||||
// For other errors (e.g. file read errors, unknown sources), wrap them as ConfigError.
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return nil, exitErr
|
||||
}
|
||||
return nil, &ConfigError{Code: 2, Type: "config", Message: err.Error()}
|
||||
}
|
||||
cfg := &CliConfig{
|
||||
|
||||
@@ -3,17 +3,12 @@
|
||||
|
||||
package keychain
|
||||
|
||||
import "fmt"
|
||||
|
||||
// defaultKeychain implements KeychainAccess using the real platform keychain.
|
||||
// defaultKeychain is the default implementation of KeychainAccess
|
||||
// that uses the package-level functions.
|
||||
type defaultKeychain struct{}
|
||||
|
||||
func (d *defaultKeychain) Get(service, account string) (string, error) {
|
||||
val := Get(service, account)
|
||||
if val == "" {
|
||||
return "", fmt.Errorf("keychain entry not found: %s/%s", service, account)
|
||||
}
|
||||
return val, nil
|
||||
return Get(service, account)
|
||||
}
|
||||
|
||||
func (d *defaultKeychain) Set(service, account, value string) error {
|
||||
|
||||
@@ -5,6 +5,15 @@
|
||||
// macOS uses the system Keychain; Linux uses AES-256-GCM encrypted files; Windows uses DPAPI + registry.
|
||||
package keychain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
var errNotInitialized = errors.New("keychain not initialized")
|
||||
|
||||
const (
|
||||
// LarkCliService is the unified keychain service name for all secrets
|
||||
// (both AppSecret and UAT). Entries are distinguished by account key format:
|
||||
@@ -13,6 +22,22 @@ const (
|
||||
LarkCliService = "lark-cli"
|
||||
)
|
||||
|
||||
// wrapError is a helper to wrap underlying errors into output.ExitError.
|
||||
// It formats the error message and provides a hint for troubleshooting keychain access issues.
|
||||
func wrapError(op string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
msg := fmt.Sprintf("keychain %s failed: %v", op, err)
|
||||
hint := "Check if the OS keychain/credential manager is locked or accessible. If running inside a sandbox or CI environment, please ensure the process has the necessary permissions to access the keychain."
|
||||
|
||||
if errors.Is(err, errNotInitialized) {
|
||||
hint = "The keychain master key may have been cleaned up or deleted. Please reconfigure the CLI by running `lark-cli config init`."
|
||||
}
|
||||
|
||||
return output.ErrWithHint(output.ExitAPI, "config", msg, hint)
|
||||
}
|
||||
|
||||
// KeychainAccess abstracts keychain Get/Set/Remove for dependency injection.
|
||||
// Used by AppSecret operations (ForStorage, ResolveSecretInput, RemoveSecretStore).
|
||||
// UAT operations in token_store.go use the package-level Get/Set/Remove directly.
|
||||
@@ -24,16 +49,17 @@ type KeychainAccess interface {
|
||||
|
||||
// Get retrieves a value from the keychain.
|
||||
// Returns empty string if the entry does not exist.
|
||||
func Get(service, account string) string {
|
||||
return platformGet(service, account)
|
||||
func Get(service, account string) (string, error) {
|
||||
val, err := platformGet(service, account)
|
||||
return val, wrapError("Get", err)
|
||||
}
|
||||
|
||||
// Set stores a value in the keychain, overwriting any existing entry.
|
||||
func Set(service, account, data string) error {
|
||||
return platformSet(service, account, data)
|
||||
return wrapError("Set", platformSet(service, account, data))
|
||||
}
|
||||
|
||||
// Remove deletes an entry from the keychain. No error if not found.
|
||||
func Remove(service, account string) error {
|
||||
return platformRemove(service, account)
|
||||
return wrapError("Remove", platformRemove(service, account))
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -36,11 +37,14 @@ func StorageDir(service string) string {
|
||||
|
||||
var safeFileNameRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
|
||||
|
||||
// safeFileName sanitizes an account name to be used as a safe file name.
|
||||
func safeFileName(account string) string {
|
||||
return safeFileNameRe.ReplaceAllString(account, "_") + ".enc"
|
||||
}
|
||||
|
||||
func getMasterKey(service string) ([]byte, error) {
|
||||
// getMasterKey retrieves the master key from the system keychain.
|
||||
// If allowCreate is true, it generates and stores a new master key if one doesn't exist.
|
||||
func getMasterKey(service string, allowCreate bool) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), keychainTimeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -59,28 +63,48 @@ func getMasterKey(service string) ([]byte, error) {
|
||||
resCh <- result{key: key, err: nil}
|
||||
return
|
||||
}
|
||||
// Key is found but invalid or corrupted
|
||||
resCh <- result{key: nil, err: errors.New("keychain is corrupted")}
|
||||
return
|
||||
} else if !errors.Is(err, keyring.ErrNotFound) {
|
||||
// Not ErrNotFound, which means access was denied or blocked by the system
|
||||
resCh <- result{key: nil, err: errors.New("keychain access blocked")}
|
||||
return
|
||||
}
|
||||
|
||||
// Generate new master key if not found or invalid
|
||||
// If ErrNotFound, check if we are allowed to create a new key
|
||||
if !allowCreate {
|
||||
// Creation not allowed (e.g., during Get operation), return error
|
||||
resCh <- result{key: nil, err: errNotInitialized}
|
||||
return
|
||||
}
|
||||
|
||||
// It's the first time and creation is allowed (Set operation), generate a new key
|
||||
key := make([]byte, masterKeyBytes)
|
||||
if _, randErr := rand.Read(key); randErr != nil {
|
||||
resCh <- result{key: nil, err: randErr}
|
||||
return
|
||||
}
|
||||
|
||||
encodedKey = base64.StdEncoding.EncodeToString(key)
|
||||
setErr := keyring.Set(service, "master.key", encodedKey)
|
||||
resCh <- result{key: key, err: setErr}
|
||||
encodedKeyStr := base64.StdEncoding.EncodeToString(key)
|
||||
setErr := keyring.Set(service, "master.key", encodedKeyStr)
|
||||
if setErr != nil {
|
||||
resCh <- result{key: nil, err: setErr}
|
||||
return
|
||||
}
|
||||
resCh <- result{key: key, err: nil}
|
||||
}()
|
||||
|
||||
select {
|
||||
case res := <-resCh:
|
||||
return res.key, res.err
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
// Timeout is usually caused by ignored/blocked permission prompts
|
||||
return nil, errors.New("keychain access blocked")
|
||||
}
|
||||
}
|
||||
|
||||
// encryptData encrypts data using AES-GCM.
|
||||
func encryptData(plaintext string, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
@@ -103,6 +127,7 @@ func encryptData(plaintext string, key []byte) ([]byte, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// decryptData decrypts data using AES-GCM.
|
||||
func decryptData(data []byte, key []byte) (string, error) {
|
||||
if len(data) < ivBytes+tagBytes {
|
||||
return "", os.ErrInvalid
|
||||
@@ -125,24 +150,30 @@ func decryptData(data []byte, key []byte) (string, error) {
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
func platformGet(service, account string) string {
|
||||
key, err := getMasterKey(service)
|
||||
if err != nil {
|
||||
return ""
|
||||
// platformGet retrieves a value from the macOS keychain.
|
||||
func platformGet(service, account string) (string, error) {
|
||||
path := filepath.Join(StorageDir(service), safeFileName(account))
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", nil
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(StorageDir(service), safeFileName(account)))
|
||||
if err != nil {
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
key, err := getMasterKey(service, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
plaintext, err := decryptData(data, key)
|
||||
if err != nil {
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
return plaintext
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// platformSet stores a value in the macOS keychain.
|
||||
func platformSet(service, account, data string) error {
|
||||
key, err := getMasterKey(service)
|
||||
key, err := getMasterKey(service, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -170,6 +201,7 @@ func platformSet(service, account, data string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// platformRemove deletes a value from the macOS keychain.
|
||||
func platformRemove(service, account string) error {
|
||||
err := os.Remove(filepath.Join(StorageDir(service), safeFileName(account)))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -21,8 +22,7 @@ const masterKeyBytes = 32
|
||||
const ivBytes = 12
|
||||
const tagBytes = 16
|
||||
|
||||
// StorageDir returns the storage directory for a given service name.
|
||||
// Each service gets its own directory for physical isolation.
|
||||
// StorageDir returns the directory where encrypted files are stored.
|
||||
func StorageDir(service string) string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
@@ -36,11 +36,14 @@ func StorageDir(service string) string {
|
||||
|
||||
var safeFileNameRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
|
||||
|
||||
// safeFileName sanitizes an account name to be used as a safe file name.
|
||||
func safeFileName(account string) string {
|
||||
return safeFileNameRe.ReplaceAllString(account, "_") + ".enc"
|
||||
}
|
||||
|
||||
func getMasterKey(service string) ([]byte, error) {
|
||||
// getMasterKey retrieves the master key from the file system.
|
||||
// If allowCreate is true, it generates and stores a new master key if one doesn't exist.
|
||||
func getMasterKey(service string, allowCreate bool) ([]byte, error) {
|
||||
dir := StorageDir(service)
|
||||
keyPath := filepath.Join(dir, "master.key")
|
||||
|
||||
@@ -48,6 +51,18 @@ func getMasterKey(service string) ([]byte, error) {
|
||||
if err == nil && len(key) == masterKeyBytes {
|
||||
return key, nil
|
||||
}
|
||||
if err == nil && len(key) != masterKeyBytes {
|
||||
// Key file exists but is corrupted
|
||||
return nil, errors.New("keychain is corrupted")
|
||||
}
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
// Real I/O error (permission denied, etc.) - propagate it
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !allowCreate {
|
||||
return nil, errNotInitialized
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, err
|
||||
@@ -78,6 +93,7 @@ func getMasterKey(service string) ([]byte, error) {
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// encryptData encrypts data using AES-GCM.
|
||||
func encryptData(plaintext string, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
@@ -100,6 +116,7 @@ func encryptData(plaintext string, key []byte) ([]byte, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// decryptData decrypts data using AES-GCM.
|
||||
func decryptData(data []byte, key []byte) (string, error) {
|
||||
if len(data) < ivBytes+tagBytes {
|
||||
return "", os.ErrInvalid
|
||||
@@ -122,24 +139,30 @@ func decryptData(data []byte, key []byte) (string, error) {
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
func platformGet(service, account string) string {
|
||||
key, err := getMasterKey(service)
|
||||
if err != nil {
|
||||
return ""
|
||||
// platformGet retrieves a value from the file system.
|
||||
func platformGet(service, account string) (string, error) {
|
||||
path := filepath.Join(StorageDir(service), safeFileName(account))
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", nil
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(StorageDir(service), safeFileName(account)))
|
||||
if err != nil {
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
key, err := getMasterKey(service, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
plaintext, err := decryptData(data, key)
|
||||
if err != nil {
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
return plaintext
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// platformSet stores a value in the file system.
|
||||
func platformSet(service, account, data string) error {
|
||||
key, err := getMasterKey(service)
|
||||
key, err := getMasterKey(service, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -167,6 +190,7 @@ func platformSet(service, account, data string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// platformRemove deletes a value from the file system.
|
||||
func platformRemove(service, account string) error {
|
||||
err := os.Remove(filepath.Join(StorageDir(service), safeFileName(account)))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
|
||||
@@ -22,12 +22,14 @@ import (
|
||||
|
||||
const regRootPath = `Software\LarkCli\keychain`
|
||||
|
||||
// registryPathForService returns the registry path for a given service.
|
||||
func registryPathForService(service string) string {
|
||||
return regRootPath + `\` + safeRegistryComponent(service)
|
||||
}
|
||||
|
||||
var safeRegRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
|
||||
|
||||
// safeRegistryComponent sanitizes a string to be used as a registry key component.
|
||||
func safeRegistryComponent(s string) string {
|
||||
// Registry key path uses '\\' separators; avoid accidental nesting and odd chars.
|
||||
s = strings.ReplaceAll(s, "\\", "_")
|
||||
@@ -39,6 +41,7 @@ func valueNameForAccount(account string) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(account))
|
||||
}
|
||||
|
||||
// dpapiEntropy generates entropy for DPAPI encryption based on the service and account names.
|
||||
func dpapiEntropy(service, account string) *windows.DataBlob {
|
||||
// Bind ciphertext to (service, account) to reduce swap/replay risks.
|
||||
// Note: empty entropy is allowed, but we intentionally use deterministic entropy.
|
||||
@@ -49,6 +52,7 @@ func dpapiEntropy(service, account string) *windows.DataBlob {
|
||||
return &windows.DataBlob{Size: uint32(len(data)), Data: &data[0]}
|
||||
}
|
||||
|
||||
// dpapiProtect encrypts data using Windows DPAPI.
|
||||
func dpapiProtect(plaintext []byte, entropy *windows.DataBlob) ([]byte, error) {
|
||||
var in windows.DataBlob
|
||||
if len(plaintext) > 0 {
|
||||
@@ -70,6 +74,7 @@ func dpapiProtect(plaintext []byte, entropy *windows.DataBlob) ([]byte, error) {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// dpapiUnprotect decrypts data using Windows DPAPI.
|
||||
func dpapiUnprotect(ciphertext []byte, entropy *windows.DataBlob) ([]byte, error) {
|
||||
var in windows.DataBlob
|
||||
if len(ciphertext) > 0 {
|
||||
@@ -91,6 +96,7 @@ func dpapiUnprotect(ciphertext []byte, entropy *windows.DataBlob) ([]byte, error
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// freeDataBlob frees the memory allocated for a DataBlob.
|
||||
func freeDataBlob(b *windows.DataBlob) {
|
||||
if b == nil || b.Data == nil {
|
||||
return
|
||||
@@ -101,11 +107,16 @@ func freeDataBlob(b *windows.DataBlob) {
|
||||
b.Size = 0
|
||||
}
|
||||
|
||||
func platformGet(service, account string) string {
|
||||
v, _ := registryGet(service, account)
|
||||
return v
|
||||
// platformGet retrieves a value from the Windows registry.
|
||||
func platformGet(service, account string) (string, error) {
|
||||
v, ok := registryGet(service, account)
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// platformSet stores a value in the Windows registry.
|
||||
func platformSet(service, account, data string) error {
|
||||
entropy := dpapiEntropy(service, account)
|
||||
protected, err := dpapiProtect([]byte(data), entropy)
|
||||
@@ -115,10 +126,12 @@ func platformSet(service, account, data string) error {
|
||||
return registrySet(service, account, protected)
|
||||
}
|
||||
|
||||
// platformRemove deletes a value from the Windows registry.
|
||||
func platformRemove(service, account string) error {
|
||||
return registryRemove(service, account)
|
||||
}
|
||||
|
||||
// registryGet retrieves a string value from the registry under the given service and account.
|
||||
func registryGet(service, account string) (string, bool) {
|
||||
keyPath := registryPathForService(service)
|
||||
k, err := registry.OpenKey(registry.CURRENT_USER, keyPath, registry.QUERY_VALUE)
|
||||
@@ -143,6 +156,7 @@ func registryGet(service, account string) (string, bool) {
|
||||
return string(plain), true
|
||||
}
|
||||
|
||||
// registrySet stores a string value in the registry under the given service and account.
|
||||
func registrySet(service, account string, protected []byte) error {
|
||||
keyPath := registryPathForService(service)
|
||||
k, _, err := registry.CreateKey(registry.CURRENT_USER, keyPath, registry.SET_VALUE)
|
||||
@@ -158,6 +172,7 @@ func registrySet(service, account string, protected []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// registryRemove deletes a value from the registry under the given service and account.
|
||||
func registryRemove(service, account string) error {
|
||||
keyPath := registryPathForService(service)
|
||||
k, err := registry.OpenKey(registry.CURRENT_USER, keyPath, registry.SET_VALUE)
|
||||
|
||||
132
internal/output/jq.go
Normal file
132
internal/output/jq.go
Normal file
@@ -0,0 +1,132 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
|
||||
"github.com/itchyny/gojq"
|
||||
)
|
||||
|
||||
// JqFilter applies a jq expression to data and writes the results to w.
|
||||
// Scalar values are printed raw (no quotes for strings), matching jq -r behavior.
|
||||
// Complex values (maps, arrays) are printed as indented JSON.
|
||||
func JqFilter(w io.Writer, data interface{}, expr string) error {
|
||||
query, err := gojq.Parse(expr)
|
||||
if err != nil {
|
||||
return ErrValidation("invalid jq expression: %s", err)
|
||||
}
|
||||
code, err := gojq.Compile(query)
|
||||
if err != nil {
|
||||
return ErrValidation("invalid jq expression: %s", err)
|
||||
}
|
||||
|
||||
// Normalize data through toGeneric so typed structs become map[string]any.
|
||||
normalized := toGeneric(data)
|
||||
// Convert json.Number values to gojq-compatible types.
|
||||
normalized = convertNumbers(normalized)
|
||||
|
||||
iter := code.Run(normalized)
|
||||
for {
|
||||
v, ok := iter.Next()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if err, isErr := v.(error); isErr {
|
||||
return Errorf(ExitAPI, "jq_error", "jq error: %s", err)
|
||||
}
|
||||
if err := writeJqValue(w, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateJqFlags checks --jq flag compatibility with --output and --format flags,
|
||||
// and validates the jq expression syntax. Returns nil if jqExpr is empty.
|
||||
func ValidateJqFlags(jqExpr, outputFlag, format string) error {
|
||||
if jqExpr == "" {
|
||||
return nil
|
||||
}
|
||||
if outputFlag != "" {
|
||||
return ErrValidation("--jq and --output are mutually exclusive")
|
||||
}
|
||||
if format != "" && format != "json" {
|
||||
return ErrValidation("--jq and --format %s are mutually exclusive", format)
|
||||
}
|
||||
return ValidateJqExpression(jqExpr)
|
||||
}
|
||||
|
||||
// ValidateJqExpression checks whether a jq expression is syntactically valid.
|
||||
func ValidateJqExpression(expr string) error {
|
||||
query, err := gojq.Parse(expr)
|
||||
if err != nil {
|
||||
return ErrValidation("invalid jq expression: %s", err)
|
||||
}
|
||||
_, err = gojq.Compile(query)
|
||||
if err != nil {
|
||||
return ErrValidation("invalid jq expression: %s", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeJqValue writes a single jq result value to w.
|
||||
// Scalars are printed raw; complex values as indented JSON.
|
||||
func writeJqValue(w io.Writer, v interface{}) error {
|
||||
switch val := v.(type) {
|
||||
case nil:
|
||||
fmt.Fprintln(w, "null")
|
||||
case bool:
|
||||
fmt.Fprintln(w, val)
|
||||
case int:
|
||||
fmt.Fprintln(w, val)
|
||||
case float64:
|
||||
// Use %g to avoid trailing zeros, matching jq behavior.
|
||||
fmt.Fprintf(w, "%g\n", val)
|
||||
case *big.Int:
|
||||
fmt.Fprintln(w, val.String())
|
||||
case string:
|
||||
// Raw output for strings (no quotes), matching jq -r.
|
||||
fmt.Fprintln(w, val)
|
||||
default:
|
||||
// Complex value (map, array): indented JSON.
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return Errorf(ExitInternal, "jq_error", "failed to marshal jq result: %s", err)
|
||||
}
|
||||
fmt.Fprintln(w, string(b))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// convertNumbers recursively converts json.Number values to int or float64
|
||||
// so that gojq can process them correctly.
|
||||
func convertNumbers(v interface{}) interface{} {
|
||||
switch val := v.(type) {
|
||||
case json.Number:
|
||||
if i, err := val.Int64(); err == nil {
|
||||
return int(i)
|
||||
}
|
||||
if f, err := val.Float64(); err == nil {
|
||||
return f
|
||||
}
|
||||
// Fallback: return as string (shouldn't happen for valid JSON numbers).
|
||||
return val.String()
|
||||
case map[string]interface{}:
|
||||
for k, elem := range val {
|
||||
val[k] = convertNumbers(elem)
|
||||
}
|
||||
return val
|
||||
case []interface{}:
|
||||
for i, elem := range val {
|
||||
val[i] = convertNumbers(elem)
|
||||
}
|
||||
return val
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
215
internal/output/jq_test.go
Normal file
215
internal/output/jq_test.go
Normal file
@@ -0,0 +1,215 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestJqFilter(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"name": "Alice", "age": 30},
|
||||
map[string]interface{}{"name": "Bob", "age": 25},
|
||||
map[string]interface{}{"name": "Charlie", "age": 35},
|
||||
},
|
||||
"total": 3,
|
||||
},
|
||||
"meta": map[string]interface{}{
|
||||
"count": 3,
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "identity expression",
|
||||
expr: ".",
|
||||
want: `"ok"`,
|
||||
},
|
||||
{
|
||||
name: "field access .ok",
|
||||
expr: ".ok",
|
||||
want: "true\n",
|
||||
},
|
||||
{
|
||||
name: "string field raw output",
|
||||
expr: ".identity",
|
||||
want: "user\n",
|
||||
},
|
||||
{
|
||||
name: "nested field access",
|
||||
expr: ".data.total",
|
||||
want: "3\n",
|
||||
},
|
||||
{
|
||||
name: "meta count",
|
||||
expr: ".meta.count",
|
||||
want: "3\n",
|
||||
},
|
||||
{
|
||||
name: "array iteration",
|
||||
expr: ".data.items[].name",
|
||||
want: "Alice\nBob\nCharlie\n",
|
||||
},
|
||||
{
|
||||
name: "pipe and select",
|
||||
expr: `.data.items[] | select(.age > 28) | .name`,
|
||||
want: "Alice\nCharlie\n",
|
||||
},
|
||||
{
|
||||
name: "length builtin",
|
||||
expr: ".data.items | length",
|
||||
want: "3\n",
|
||||
},
|
||||
{
|
||||
name: "keys builtin",
|
||||
expr: ".data | keys",
|
||||
want: "[\n \"items\",\n \"total\"\n]\n",
|
||||
},
|
||||
{
|
||||
name: "null for missing field",
|
||||
expr: ".nonexistent",
|
||||
want: "null\n",
|
||||
},
|
||||
{
|
||||
name: "complex value output",
|
||||
expr: ".data.items[0]",
|
||||
want: "{\n \"age\": 30,\n \"name\": \"Alice\"\n}\n",
|
||||
},
|
||||
{
|
||||
name: "invalid expression",
|
||||
expr: "invalid[",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "multiple outputs",
|
||||
expr: ".ok, .identity",
|
||||
want: "true\nuser\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := JqFilter(&buf, data, tt.expr)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tt.name == "identity expression" {
|
||||
// For identity, just verify it contains the key fields
|
||||
if !strings.Contains(buf.String(), `"ok"`) {
|
||||
t.Errorf("identity output missing 'ok' key")
|
||||
}
|
||||
return
|
||||
}
|
||||
if buf.String() != tt.want {
|
||||
t.Errorf("got %q, want %q", buf.String(), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJqFilter_WithStruct(t *testing.T) {
|
||||
// Test that toGeneric normalizes structs properly
|
||||
type inner struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
data := struct {
|
||||
OK bool `json:"ok"`
|
||||
Item *inner `json:"item"`
|
||||
}{
|
||||
OK: true,
|
||||
Item: &inner{Name: "test"},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := JqFilter(&buf, data, ".item.name")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(buf.String()); got != "test" {
|
||||
t.Errorf("got %q, want %q", got, "test")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateJqFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
jqExpr string
|
||||
outputFlag string
|
||||
format string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "empty jq is noop", jqExpr: "", outputFlag: "file.json", format: "csv", wantErr: ""},
|
||||
{name: "jq only", jqExpr: ".data", outputFlag: "", format: "", wantErr: ""},
|
||||
{name: "jq with json format", jqExpr: ".data", outputFlag: "", format: "json", wantErr: ""},
|
||||
{name: "jq and output conflict", jqExpr: ".data", outputFlag: "out.json", format: "", wantErr: "--jq and --output are mutually exclusive"},
|
||||
{name: "jq and csv conflict", jqExpr: ".data", outputFlag: "", format: "csv", wantErr: "--jq and --format csv are mutually exclusive"},
|
||||
{name: "jq and ndjson conflict", jqExpr: ".data", outputFlag: "", format: "ndjson", wantErr: "--jq and --format ndjson are mutually exclusive"},
|
||||
{name: "invalid expression", jqExpr: "invalid[", outputFlag: "", format: "", wantErr: "invalid jq expression"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateJqFlags(tt.jqExpr, tt.outputFlag, tt.format)
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Errorf("expected error containing %q, got nil", tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Errorf("error %q does not contain %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateJqExpression(t *testing.T) {
|
||||
tests := []struct {
|
||||
expr string
|
||||
wantErr bool
|
||||
}{
|
||||
{".", false},
|
||||
{".data", false},
|
||||
{".data.items[].name", false},
|
||||
{`.data.items[] | select(.name == "Alice")`, false},
|
||||
{"length", false},
|
||||
{"keys", false},
|
||||
{"invalid[", true},
|
||||
{".foo | invalid_func", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.expr, func(t *testing.T) {
|
||||
err := ValidateJqExpression(tt.expr)
|
||||
if tt.wantErr && err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"approval": {
|
||||
"en": { "title": "Approval", "description": "Approval instance, and task management" },
|
||||
"zh": { "title": "审批", "description": "审批实例、审批任务管理" }
|
||||
},
|
||||
"base": {
|
||||
"en": { "title": "Base", "description": "Table, field, record, view, dashboard, workflow, form, role & permission management" },
|
||||
"zh": { "title": "多维表格", "description": "数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限管理" }
|
||||
|
||||
@@ -181,6 +181,25 @@ func cloneDownloadTransport(base http.RoundTripper) *http.Transport {
|
||||
return cloned
|
||||
}
|
||||
|
||||
// DialContextFunc is the signature for DialContext / DialTLSContext.
|
||||
type DialContextFunc func(ctx context.Context, network, addr string) (net.Conn, error)
|
||||
|
||||
// WrapDialContextWithIPCheck wraps a DialContext function to validate the
|
||||
// remote IP after connection, rejecting local/internal addresses (SSRF protection).
|
||||
func WrapDialContextWithIPCheck(origDial DialContextFunc) DialContextFunc {
|
||||
return func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
conn, err := dialConn(ctx, origDial, network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateConnRemoteIP(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
}
|
||||
|
||||
func dialConn(ctx context.Context, dialFn func(context.Context, string, string) (net.Conn, error), network, addr string) (net.Conn, error) {
|
||||
if dialFn != nil {
|
||||
return dialFn(ctx, network, addr)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.3",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -67,8 +67,24 @@ def main():
|
||||
parser = argparse.ArgumentParser(description="Fetch meta_data.json for build-time embedding")
|
||||
parser.add_argument("--brand", default="feishu", choices=["feishu", "lark"],
|
||||
help="API brand (default: feishu)")
|
||||
parser.add_argument("--force", action="store_true",
|
||||
help="force refresh from remote even if local file exists")
|
||||
args = parser.parse_args()
|
||||
|
||||
if os.path.exists(OUT_PATH) and not args.force:
|
||||
if os.path.isfile(OUT_PATH):
|
||||
try:
|
||||
with open(OUT_PATH, "r", encoding="utf-8") as fp:
|
||||
local = json.load(fp)
|
||||
if local.get("services"):
|
||||
print(f"fetch-meta: {OUT_PATH} already exists, skipping (use --force to re-fetch)", file=sys.stderr)
|
||||
return
|
||||
print(f"fetch-meta: {OUT_PATH} has no services, re-fetching", file=sys.stderr)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
print(f"fetch-meta: {OUT_PATH} is invalid JSON, re-fetching", file=sys.stderr)
|
||||
else:
|
||||
print(f"fetch-meta: {OUT_PATH} is not a file, re-fetching", file=sys.stderr)
|
||||
|
||||
data = fetch_remote(args.brand)
|
||||
count = len(data.get("services", []))
|
||||
print(f"fetch-meta: OK, {count} services from remote API", file=sys.stderr)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const https = require("https");
|
||||
const { execSync } = require("child_process");
|
||||
const os = require("os");
|
||||
|
||||
@@ -32,45 +31,34 @@ if (!platform || !arch) {
|
||||
const isWindows = process.platform === "win32";
|
||||
const ext = isWindows ? ".zip" : ".tar.gz";
|
||||
const archiveName = `${NAME}-${VERSION}-${platform}-${arch}${ext}`;
|
||||
const url = `https://github.com/${REPO}/releases/download/v${VERSION}/${archiveName}`;
|
||||
const GITHUB_URL = `https://github.com/${REPO}/releases/download/v${VERSION}/${archiveName}`;
|
||||
const MIRROR_URL = `https://registry.npmmirror.com/-/binary/lark-cli/v${VERSION}/${archiveName}`;
|
||||
|
||||
const binDir = path.join(__dirname, "..", "bin");
|
||||
const dest = path.join(binDir, NAME + (isWindows ? ".exe" : ""));
|
||||
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
|
||||
function download(url, destPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = url.startsWith("https") ? https : require("http");
|
||||
client
|
||||
.get(url, (res) => {
|
||||
if (res.statusCode === 302 || res.statusCode === 301) {
|
||||
return download(res.headers.location, destPath).then(
|
||||
resolve,
|
||||
reject
|
||||
);
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
return reject(
|
||||
new Error(`Download failed with status ${res.statusCode}: ${url}`)
|
||||
);
|
||||
}
|
||||
const file = fs.createWriteStream(destPath);
|
||||
res.pipe(file);
|
||||
file.on("finish", () => {
|
||||
file.close();
|
||||
resolve();
|
||||
});
|
||||
})
|
||||
.on("error", reject);
|
||||
});
|
||||
// --ssl-revoke-best-effort: on Windows (Schannel), avoid CRYPT_E_REVOCATION_OFFLINE
|
||||
// errors when the certificate revocation list server is unreachable
|
||||
const sslFlag = isWindows ? "--ssl-revoke-best-effort " : "";
|
||||
execSync(
|
||||
`curl ${sslFlag}--fail --location --silent --show-error --connect-timeout 10 --max-time 120 --output "${destPath}" "${url}"`,
|
||||
{ stdio: ["ignore", "ignore", "pipe"] }
|
||||
);
|
||||
}
|
||||
|
||||
async function install() {
|
||||
function install() {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lark-cli-"));
|
||||
const archivePath = path.join(tmpDir, archiveName);
|
||||
|
||||
try {
|
||||
await download(url, archivePath);
|
||||
try {
|
||||
download(GITHUB_URL, archivePath);
|
||||
} catch (err) {
|
||||
download(MIRROR_URL, archivePath);
|
||||
}
|
||||
|
||||
if (isWindows) {
|
||||
execSync(
|
||||
@@ -94,7 +82,14 @@ async function install() {
|
||||
}
|
||||
}
|
||||
|
||||
install().catch((err) => {
|
||||
try {
|
||||
install();
|
||||
} catch (err) {
|
||||
console.error(`Failed to install ${NAME}:`, err.message);
|
||||
console.error(
|
||||
`\nIf you are behind a firewall or in a restricted network, try setting a proxy:\n` +
|
||||
` export https_proxy=http://your-proxy:port\n` +
|
||||
` npm install -g @larksuite/cli`
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
58
scripts/pr-labels/README.md
Normal file
58
scripts/pr-labels/README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# PR Label Sync
|
||||
|
||||
This directory contains scripts and sample data for automatically classifying and labeling GitHub Pull Requests based on the files they modify.
|
||||
|
||||
## Files
|
||||
|
||||
- `index.js`: The main Node.js script. It fetches PR files, evaluates their risk level, calculates business impact, and uses GitHub APIs to add appropriate `size/*` and `domain/*` labels.
|
||||
- `samples.json`: A collection of historical PRs used as test cases to verify the labeling logic (especially for regression testing the S/M/L thresholds).
|
||||
|
||||
## Features
|
||||
|
||||
### Size Labels (`size/*`)
|
||||
The script evaluates the "effective" lines of code changed (ignoring tests, docs, and ci files) to classify the PR:
|
||||
- **`size/S`**: Low-risk changes involving only docs, tests, CI workflows, or chores.
|
||||
- **`size/M`**: Small-to-medium changes affecting a single business domain, with effective lines under 300.
|
||||
- **`size/L`**: Large features (>= 300 lines), cross-domain changes, or any changes touching core architecture paths (like `cmd/`).
|
||||
- **`size/XL`**: Architectural overhauls, extremely large PRs (>1200 lines), or sensitive refactors.
|
||||
|
||||
### Domain Tags (`domain/*`)
|
||||
The script also identifies which business domains a PR touches to give reviewers an immediate sense of the impact scope. Currently tracked domains include:
|
||||
- `domain/im`
|
||||
- `domain/vc`
|
||||
- `domain/ccm`
|
||||
- `domain/base`
|
||||
- `domain/mail`
|
||||
- `domain/calendar`
|
||||
- `domain/task`
|
||||
- `domain/contact`
|
||||
|
||||
Minor modules like docs and tests are omitted to keep PR tags clean and focused on structural changes.
|
||||
|
||||
## Usage
|
||||
|
||||
### In GitHub Actions
|
||||
This script is designed to run in CI workflows. It automatically reads the `GITHUB_EVENT_PATH` payload to get the PR context.
|
||||
|
||||
```bash
|
||||
node scripts/pr-labels/index.js
|
||||
```
|
||||
|
||||
### Local Dry Run
|
||||
You can test the labeling logic against an existing GitHub PR without actually applying labels by using the `--dry-run` flag.
|
||||
|
||||
```bash
|
||||
# Requires GITHUB_TOKEN environment variable or passing --token
|
||||
node scripts/pr-labels/index.js --dry-run --repo larksuite/cli --pr-number 123
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
A regression test suite is available in `test.js` which verifies the output of the classification logic against historical PRs configured in `samples.json`.
|
||||
|
||||
```bash
|
||||
# Requires GITHUB_TOKEN environment variable to avoid rate limits
|
||||
GITHUB_TOKEN=$(gh auth token) node scripts/pr-labels/test.js
|
||||
```
|
||||
|
||||
This test suite also runs automatically in CI via `.github/workflows/pr-labels-test.yml` when changes are made to this directory.
|
||||
747
scripts/pr-labels/index.js
Executable file
747
scripts/pr-labels/index.js
Executable file
@@ -0,0 +1,747 @@
|
||||
#!/usr/bin/env node
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
|
||||
// ============================================================================
|
||||
// Constants & Configuration
|
||||
// ============================================================================
|
||||
|
||||
const API_BASE = "https://api.github.com";
|
||||
const SCRIPT_DIR = __dirname;
|
||||
const ROOT = path.join(SCRIPT_DIR, "..", "..");
|
||||
|
||||
const THRESHOLD_L = 300;
|
||||
const THRESHOLD_XL = 1200;
|
||||
|
||||
const LABEL_DEFINITIONS = {
|
||||
"size/S": { color: "77bb00", description: "Low-risk docs, CI, test, or chore only changes" },
|
||||
"size/M": { color: "eebb00", description: "Single-domain feat or fix with limited business impact" },
|
||||
"size/L": { color: "ff8800", description: "Large or sensitive change across domains or core paths" },
|
||||
"size/XL": { color: "ee0000", description: "Architecture-level or global-impact change" },
|
||||
};
|
||||
|
||||
const MANAGED_LABELS = new Set(Object.keys(LABEL_DEFINITIONS));
|
||||
|
||||
// File path matching configurations
|
||||
const DOC_SUFFIXES = [".md", ".mdx", ".txt", ".rst"];
|
||||
const LOW_RISK_PREFIXES = [".github/", "docs/", ".changeset/", "testdata/", "tests/", "skill-template/"];
|
||||
const LOW_RISK_FILENAMES = new Set(["readme.md", "readme.zh.md", "changelog.md", "license", "cla.md"]);
|
||||
const LOW_RISK_TEST_SUFFIXES = ["_test.go", ".snap"];
|
||||
|
||||
const CORE_PREFIXES = ["internal/auth/", "internal/engine/", "internal/config/", "cmd/"];
|
||||
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/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-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 = {
|
||||
"size/S": {
|
||||
channel: "Fast track (S)",
|
||||
gates: [
|
||||
"Code quality: AI code review passed",
|
||||
"Dependency and configuration security checks passed",
|
||||
],
|
||||
},
|
||||
"size/M": {
|
||||
channel: "Fast track (M)",
|
||||
gates: [
|
||||
"Code quality: AI code review passed",
|
||||
"Dependency and configuration security checks passed",
|
||||
"Skill format validation: added or modified Skills load successfully",
|
||||
"CLI automation tests: all required business-line tests passed",
|
||||
],
|
||||
},
|
||||
"size/L": {
|
||||
channel: "Standard track (L)",
|
||||
gates: [
|
||||
"Code quality: AI code review passed",
|
||||
"Dependency and configuration security checks passed",
|
||||
"Skill format validation: added or modified Skills load successfully",
|
||||
"CLI automation tests: all required business-line tests passed",
|
||||
"Domain evaluation passed: reported success rate is greater than 95%",
|
||||
],
|
||||
},
|
||||
"size/XL": {
|
||||
channel: "Strict track (XL)",
|
||||
gates: [
|
||||
"Code quality: AI code review passed",
|
||||
"Dependency and configuration security checks passed",
|
||||
"Skill format validation: added or modified Skills load successfully",
|
||||
"CLI automation tests: all required business-line tests passed",
|
||||
"Domain evaluation passed: reported success rate is greater than 95%",
|
||||
"Cross-domain release gate: all domains and full integration evaluations passed",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Utilities
|
||||
// ============================================================================
|
||||
|
||||
function log(message) {
|
||||
console.error(`sync-pr-labels: ${message}`);
|
||||
}
|
||||
|
||||
function normalizePath(input) {
|
||||
return String(input || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function envValue(name) {
|
||||
return (process.env[name] || "").trim();
|
||||
}
|
||||
|
||||
function envOrFail(name) {
|
||||
const value = envValue(name);
|
||||
if (!value) {
|
||||
throw new Error(`missing required environment variable: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GitHub API Client
|
||||
// ============================================================================
|
||||
|
||||
class GitHubClient {
|
||||
constructor(token, repo, prNumber) {
|
||||
this.token = token;
|
||||
this.repo = repo;
|
||||
this.prNumber = prNumber;
|
||||
}
|
||||
|
||||
buildHeaders(hasBody = false) {
|
||||
const headers = {
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
};
|
||||
if (this.token) {
|
||||
headers.Authorization = `Bearer ${this.token}`;
|
||||
}
|
||||
if (hasBody) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async request(endpoint, options = {}) {
|
||||
const { method = "GET", payload, allow404 = false } = options;
|
||||
const hasBody = payload !== undefined;
|
||||
const url = endpoint.startsWith("http") ? endpoint : `${API_BASE}${endpoint}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: this.buildHeaders(hasBody),
|
||||
body: hasBody ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
|
||||
if (allow404 && response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
const error = new Error(`GitHub API ${method} ${url} failed: ${response.status} ${detail}`);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
return text ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
async getPullRequest() {
|
||||
return this.request(`/repos/${this.repo}/pulls/${this.prNumber}`);
|
||||
}
|
||||
|
||||
async listPrFiles() {
|
||||
const files = [];
|
||||
for (let page = 1; ; page += 1) {
|
||||
const params = new URLSearchParams({ per_page: "100", page: String(page) });
|
||||
const batch = await this.request(`/repos/${this.repo}/pulls/${this.prNumber}/files?${params}`);
|
||||
if (!batch || batch.length === 0) {
|
||||
break;
|
||||
}
|
||||
files.push(...batch);
|
||||
if (batch.length < 100) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async listIssueLabels() {
|
||||
const labels = await this.request(`/repos/${this.repo}/issues/${this.prNumber}/labels`);
|
||||
return new Set(labels.map((item) => item.name));
|
||||
}
|
||||
|
||||
async syncLabelDefinition(name) {
|
||||
const label = LABEL_DEFINITIONS[name];
|
||||
const createUrl = `/repos/${this.repo}/labels`;
|
||||
const updateUrl = `/repos/${this.repo}/labels/${encodeURIComponent(name)}`;
|
||||
|
||||
try {
|
||||
await this.request(createUrl, {
|
||||
method: "POST",
|
||||
payload: { name, color: label.color, description: label.description },
|
||||
});
|
||||
log(`created label ${name}`);
|
||||
} catch (error) {
|
||||
if (error.status !== 422) {
|
||||
throw error;
|
||||
}
|
||||
await this.request(updateUrl, {
|
||||
method: "PATCH",
|
||||
payload: { new_name: name, color: label.color, description: label.description },
|
||||
});
|
||||
log(`updated label ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
async addLabels(labels) {
|
||||
if (labels.length === 0) return;
|
||||
await this.request(`/repos/${this.repo}/issues/${this.prNumber}/labels`, {
|
||||
method: "POST",
|
||||
payload: { labels },
|
||||
});
|
||||
log(`added labels: ${labels.join(", ")}`);
|
||||
}
|
||||
|
||||
async removeLabel(name) {
|
||||
await this.request(`/repos/${this.repo}/issues/${this.prNumber}/labels/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
allow404: true,
|
||||
});
|
||||
log(`removed label: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Path & Domain Heuristics
|
||||
// ============================================================================
|
||||
|
||||
function parsePrType(title) {
|
||||
const match = String(title || "").trim().match(/^([a-z]+)(?:\([^)]+\))?!?:/i);
|
||||
return match ? match[1].toLowerCase() : "";
|
||||
}
|
||||
|
||||
function isLowRiskPath(filePath) {
|
||||
const normalized = normalizePath(filePath);
|
||||
const basename = path.posix.basename(normalized);
|
||||
|
||||
if (normalized.startsWith("skills/lark-")) return false;
|
||||
if (DOC_SUFFIXES.some((suffix) => normalized.endsWith(suffix))) return true;
|
||||
if (LOW_RISK_FILENAMES.has(basename)) return true;
|
||||
if (LOW_RISK_PREFIXES.some((prefix) => normalized.startsWith(prefix))) return true;
|
||||
if (LOW_RISK_TEST_SUFFIXES.some((suffix) => normalized.endsWith(suffix))) return true;
|
||||
return normalized.includes("/testdata/");
|
||||
}
|
||||
|
||||
function isBusinessSkillPath(filePath) {
|
||||
const normalized = normalizePath(filePath);
|
||||
return normalized.startsWith("shortcuts/") || normalized.startsWith("skills/lark-");
|
||||
}
|
||||
|
||||
function shortcutDomainForPath(filePath) {
|
||||
const parts = normalizePath(filePath).split("/");
|
||||
return parts.length >= 2 && parts[0] === "shortcuts" ? parts[1] : "";
|
||||
}
|
||||
|
||||
function skillDomainForPath(filePath) {
|
||||
const parts = normalizePath(filePath).split("/");
|
||||
return parts.length >= 2 && parts[0] === "skills" && parts[1].startsWith("lark-")
|
||||
? parts[1].slice("lark-".length)
|
||||
: "";
|
||||
}
|
||||
|
||||
// Get business domain label based on CODEOWNERS path mapping
|
||||
function getBusinessDomain(filePath) {
|
||||
const normalized = normalizePath(filePath);
|
||||
for (const [prefix, domain] of Object.entries(PATH_TO_DOMAIN_MAP)) {
|
||||
if (normalized.startsWith(prefix)) {
|
||||
return domain;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function detectNewShortcutDomain(files) {
|
||||
for (const item of files) {
|
||||
if (item.status !== "added") continue;
|
||||
const domain = shortcutDomainForPath(item.filename);
|
||||
if (!domain) continue;
|
||||
try {
|
||||
await fs.access(path.join(ROOT, "shortcuts", domain));
|
||||
} catch {
|
||||
return domain;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function collectCoreAreas(filenames) {
|
||||
const areas = new Set();
|
||||
for (const name of filenames) {
|
||||
const normalized = normalizePath(name);
|
||||
for (const prefix of CORE_PREFIXES) {
|
||||
if (normalized.startsWith(prefix)) {
|
||||
// remove trailing slash for area name
|
||||
areas.add(prefix.slice(0, -1));
|
||||
}
|
||||
}
|
||||
}
|
||||
return areas;
|
||||
}
|
||||
|
||||
function collectSensitiveKeywords(filenames) {
|
||||
const hits = new Set();
|
||||
for (const name of filenames) {
|
||||
const match = normalizePath(name).match(SENSITIVE_PATTERN);
|
||||
if (match && match[2]) {
|
||||
hits.add(match[2]);
|
||||
}
|
||||
}
|
||||
return [...hits].sort();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Classification Logic
|
||||
// ============================================================================
|
||||
|
||||
function evaluateRules(context) {
|
||||
const {
|
||||
prType, effectiveChanges, lowRiskOnly,
|
||||
domains, headDomains, coreAreas, coreSignals,
|
||||
sensitiveKeywords, sensitive, newShortcutDomain,
|
||||
singleDomain, multiDomain, filenames
|
||||
} = context;
|
||||
|
||||
const reasons = [];
|
||||
let label;
|
||||
|
||||
if (lowRiskOnly && (LOW_RISK_TYPES.has(prType) || effectiveChanges === 0)) {
|
||||
reasons.push("Only low-risk docs, CI, test, or chore paths were changed, with no effective business code or Skill changes");
|
||||
label = "size/S";
|
||||
return { label, reasons };
|
||||
}
|
||||
|
||||
// XL is reserved for architecture-level or global-impact changes.
|
||||
const isXL =
|
||||
effectiveChanges > THRESHOLD_XL ||
|
||||
(prType === "refactor" && sensitive && effectiveChanges >= THRESHOLD_L) ||
|
||||
(coreAreas.size >= 2 && (multiDomain || effectiveChanges >= THRESHOLD_L)) ||
|
||||
(headDomains.length >= 2 && sensitive);
|
||||
|
||||
if (isXL) {
|
||||
if (effectiveChanges > THRESHOLD_XL) reasons.push("Effective business code or Skill changes are far beyond the L threshold");
|
||||
if (prType === "refactor" && sensitive && effectiveChanges >= THRESHOLD_L) reasons.push("Refactor PR touches core or sensitive paths");
|
||||
if (coreAreas.size >= 2) reasons.push("Touches multiple core areas at the same time");
|
||||
if (headDomains.length >= 2) reasons.push("Impacts multiple major business domains");
|
||||
coreSignals.forEach((signal) => reasons.push(`Core area hit: ${signal}`));
|
||||
sensitiveKeywords.forEach((keyword) => reasons.push(`Sensitive keyword hit: ${keyword}`));
|
||||
label = "size/XL";
|
||||
} else if (
|
||||
prType === "refactor" ||
|
||||
effectiveChanges >= THRESHOLD_L ||
|
||||
Boolean(newShortcutDomain) ||
|
||||
multiDomain ||
|
||||
sensitive
|
||||
) {
|
||||
if (prType === "refactor") reasons.push("PR type is refactor");
|
||||
if (effectiveChanges >= THRESHOLD_L) reasons.push(`Effective business code or Skill changes exceed ${THRESHOLD_L} lines`);
|
||||
if (newShortcutDomain) reasons.push(`Introduces a new business domain directory: shortcuts/${newShortcutDomain}/`);
|
||||
if (multiDomain) reasons.push("Touches multiple business domains");
|
||||
coreSignals.forEach((signal) => reasons.push(`Core area hit: ${signal}`));
|
||||
sensitiveKeywords.forEach((keyword) => reasons.push(`Sensitive keyword hit: ${keyword}`));
|
||||
label = "size/L";
|
||||
} else {
|
||||
if (filenames.some(isBusinessSkillPath) || effectiveChanges > 0) {
|
||||
reasons.push("Regular feat, fix, or Skill change within a single business domain");
|
||||
}
|
||||
if (singleDomain && domains.size > 0) {
|
||||
reasons.push(`Impact is limited to a single business domain: ${[...domains].sort().join(", ")}`);
|
||||
}
|
||||
if (effectiveChanges < THRESHOLD_L) {
|
||||
reasons.push(`Effective business code or Skill changes are below ${THRESHOLD_L} lines`);
|
||||
}
|
||||
label = "size/M";
|
||||
}
|
||||
|
||||
return { label, reasons };
|
||||
}
|
||||
|
||||
async function classifyPr(payload, files) {
|
||||
const pr = payload.pull_request;
|
||||
const title = pr.title || "";
|
||||
const prType = parsePrType(title);
|
||||
const filenames = files.map((item) => item.filename || "");
|
||||
const impactedPaths = files.flatMap((item) => {
|
||||
const paths = [item.filename || ""];
|
||||
if (item.status === "renamed" && item.previous_filename) {
|
||||
paths.push(item.previous_filename);
|
||||
}
|
||||
return paths.filter(Boolean);
|
||||
});
|
||||
|
||||
// Filter out docs, tests, and other low-risk paths so the size label tracks business impact.
|
||||
const effectiveChanges = files.reduce(
|
||||
(sum, item) => sum + (isLowRiskPath(item.filename) ? 0 : (item.changes || 0)),
|
||||
0,
|
||||
);
|
||||
const totalChanges = files.reduce((sum, item) => sum + (item.changes || 0), 0);
|
||||
|
||||
const domains = new Set();
|
||||
const businessDomains = new Set();
|
||||
|
||||
for (const name of impactedPaths) {
|
||||
const businessDomain = getBusinessDomain(name);
|
||||
if (businessDomain) {
|
||||
businessDomains.add(businessDomain);
|
||||
domains.add(businessDomain);
|
||||
continue;
|
||||
}
|
||||
|
||||
const shortcutDomain = shortcutDomainForPath(name);
|
||||
if (shortcutDomain) domains.add(shortcutDomain);
|
||||
|
||||
const skillDomain = skillDomainForPath(name);
|
||||
if (skillDomain) domains.add(skillDomain);
|
||||
}
|
||||
|
||||
const coreAreas = collectCoreAreas(impactedPaths);
|
||||
const newShortcutDomain = await detectNewShortcutDomain(files);
|
||||
|
||||
const lowRiskOnly = impactedPaths.length > 0 && impactedPaths.every(isLowRiskPath);
|
||||
const singleDomain = domains.size <= 1;
|
||||
const multiDomain = domains.size >= 2;
|
||||
const headDomains = [...domains].filter((domain) => HEAD_BUSINESS_DOMAINS.has(domain));
|
||||
const coreSignals = [...coreAreas].sort();
|
||||
const sensitiveKeywords = collectSensitiveKeywords(impactedPaths);
|
||||
const sensitive = coreSignals.length > 0 || sensitiveKeywords.length > 0;
|
||||
|
||||
const context = {
|
||||
prType, effectiveChanges, lowRiskOnly,
|
||||
domains, headDomains, coreAreas, coreSignals,
|
||||
sensitiveKeywords, sensitive, newShortcutDomain,
|
||||
singleDomain, multiDomain, filenames: impactedPaths
|
||||
};
|
||||
|
||||
const { label, reasons } = evaluateRules(context);
|
||||
|
||||
return {
|
||||
label,
|
||||
title,
|
||||
prType: prType || "unknown",
|
||||
totalChanges,
|
||||
effectiveChanges,
|
||||
domains: [...domains].sort(),
|
||||
businessDomains: [...businessDomains].sort(),
|
||||
coreAreas: [...coreAreas].sort(),
|
||||
coreSignals,
|
||||
sensitiveKeywords,
|
||||
newShortcutDomain,
|
||||
reasons,
|
||||
lowRiskOnly,
|
||||
filenames,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Output & Formatting
|
||||
// ============================================================================
|
||||
|
||||
async function writeStepSummary(prNumber, classification) {
|
||||
const summaryPath = (process.env.GITHUB_STEP_SUMMARY || "").trim();
|
||||
if (!summaryPath) return;
|
||||
|
||||
const standard = CLASS_STANDARDS[classification.label];
|
||||
const domains = classification.domains.join(", ") || "-";
|
||||
const bDomains = classification.businessDomains.join(", ") || "-";
|
||||
const coreAreas = classification.coreAreas.join(", ") || "-";
|
||||
const reasons = classification.reasons.length > 0
|
||||
? classification.reasons
|
||||
: ["No higher-severity rule matched, so the PR defaults to medium classification"];
|
||||
|
||||
const lines = [
|
||||
"## PR Size Classification",
|
||||
"",
|
||||
`- PR: #${prNumber}`,
|
||||
`- Label: \`${classification.label}\``,
|
||||
`- PR Type: \`${classification.prType}\``,
|
||||
`- Total Changes: \`${classification.totalChanges}\``,
|
||||
`- Effective Business/SKILL Changes: \`${classification.effectiveChanges}\``,
|
||||
`- Business Domains: \`${domains}\``,
|
||||
`- Impacted Domains: \`${bDomains}\``,
|
||||
`- Core Areas: \`${coreAreas}\``,
|
||||
`- CI/CD Channel: \`${standard.channel}\``,
|
||||
`- Low Risk Only: \`${classification.lowRiskOnly}\``,
|
||||
"",
|
||||
"### Reasons",
|
||||
"",
|
||||
...reasons.map((reason) => `- ${reason}`),
|
||||
"",
|
||||
"### Pipeline Gates",
|
||||
"",
|
||||
...standard.gates.map((gate) => `- ${gate}`),
|
||||
"",
|
||||
];
|
||||
|
||||
await fs.appendFile(summaryPath, `${lines.join("\n")}\n`, "utf8");
|
||||
}
|
||||
|
||||
function formatDryRunResult(repo, prNumber, classification) {
|
||||
const standard = CLASS_STANDARDS[classification.label];
|
||||
return {
|
||||
repo,
|
||||
prNumber,
|
||||
label: classification.label,
|
||||
prType: classification.prType,
|
||||
totalChanges: classification.totalChanges,
|
||||
effectiveChanges: classification.effectiveChanges,
|
||||
lowRiskOnly: classification.lowRiskOnly,
|
||||
domains: classification.domains,
|
||||
businessDomains: classification.businessDomains,
|
||||
coreAreas: classification.coreAreas,
|
||||
coreSignals: classification.coreSignals,
|
||||
sensitiveKeywords: classification.sensitiveKeywords,
|
||||
reasons: classification.reasons,
|
||||
channel: standard.channel,
|
||||
gates: standard.gates,
|
||||
};
|
||||
}
|
||||
|
||||
function printDryRunResult(result, options) {
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const signalParts = [
|
||||
...result.coreSignals.map((signal) => `core:${signal}`),
|
||||
...result.sensitiveKeywords.map((keyword) => `keyword:${keyword}`),
|
||||
...(result.domains.length > 0 ? [`domains:${result.domains.join(",")}`] : []),
|
||||
];
|
||||
const reasonParts = result.reasons.length > 0
|
||||
? result.reasons
|
||||
: ["No higher-severity rule matched, so the PR defaults to medium classification"];
|
||||
|
||||
console.log(
|
||||
`${result.label} | #${result.prNumber} | type:${result.prType} | eff:${result.effectiveChanges} | `
|
||||
+ `sig:${signalParts.join(";") || "-"} | reason:${reasonParts.join("; ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
const lines = [
|
||||
"Usage:",
|
||||
" node scripts/pr-labels/index.js",
|
||||
" node scripts/pr-labels/index.js --dry-run --pr-url <github-pr-url> [--token <token>] [--json]",
|
||||
" node scripts/pr-labels/index.js --dry-run --repo <owner/name> --pr-number <number> [--token <token>] [--json]",
|
||||
"",
|
||||
"Modes:",
|
||||
" default Read the GitHub Actions event payload and apply labels",
|
||||
" --dry-run Fetch the PR, compute the managed label, and print the result without writing labels",
|
||||
"",
|
||||
"Options:",
|
||||
" --pr-url <url> GitHub pull request URL, for example https://github.com/larksuite/cli/pull/123",
|
||||
" --repo <owner/name> Repository name, used with --pr-number",
|
||||
" --pr-number <n> Pull request number, used with --repo",
|
||||
" --token <token> GitHub token override; falls back to GITHUB_TOKEN",
|
||||
" --json Print dry-run output as JSON instead of the default one-line summary",
|
||||
" --help Show this message",
|
||||
];
|
||||
console.log(lines.join("\n"));
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
dryRun: false,
|
||||
json: false,
|
||||
help: false,
|
||||
prUrl: "",
|
||||
repo: "",
|
||||
prNumber: "",
|
||||
token: "",
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--dry-run") options.dryRun = true;
|
||||
else if (arg === "--json") options.json = true;
|
||||
else if (arg === "--help" || arg === "-h") options.help = true;
|
||||
else if (arg === "--pr-url") options.prUrl = argv[++i] || "";
|
||||
else if (arg === "--repo") options.repo = argv[++i] || "";
|
||||
else if (arg === "--pr-number") options.prNumber = argv[++i] || "";
|
||||
else if (arg === "--token") options.token = argv[++i] || "";
|
||||
else throw new Error(`unknown argument: ${arg}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function parsePrUrl(prUrl) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(prUrl);
|
||||
} catch {
|
||||
throw new Error(`invalid PR URL: ${prUrl}`);
|
||||
}
|
||||
|
||||
const match = parsed.pathname.match(/^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/);
|
||||
if (!match) throw new Error(`unsupported PR URL format: ${prUrl}`);
|
||||
|
||||
return { repo: `${match[1]}/${match[2]}`, prNumber: Number(match[3]) };
|
||||
}
|
||||
|
||||
async function loadEventPayload(filePath) {
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8"));
|
||||
}
|
||||
|
||||
async function resolveContext(options) {
|
||||
const token = options.token;
|
||||
|
||||
if (options.prUrl) {
|
||||
const { repo, prNumber } = parsePrUrl(options.prUrl);
|
||||
const client = new GitHubClient(token, repo, prNumber);
|
||||
const payload = {
|
||||
repository: { full_name: repo },
|
||||
pull_request: await client.getPullRequest(),
|
||||
};
|
||||
return { repo, prNumber, payload, client };
|
||||
}
|
||||
|
||||
if (options.repo || options.prNumber) {
|
||||
if (!options.repo || !options.prNumber) throw new Error("--repo and --pr-number must be provided together");
|
||||
const prNumber = Number(options.prNumber);
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error(`invalid PR number: ${options.prNumber}`);
|
||||
|
||||
const client = new GitHubClient(token, options.repo, prNumber);
|
||||
const payload = {
|
||||
repository: { full_name: options.repo },
|
||||
pull_request: await client.getPullRequest(),
|
||||
};
|
||||
return { repo: options.repo, prNumber, payload, client };
|
||||
}
|
||||
|
||||
const eventPath = envOrFail("GITHUB_EVENT_PATH");
|
||||
const payload = await loadEventPayload(eventPath);
|
||||
const repo = payload.repository.full_name;
|
||||
const prNumber = payload.pull_request.number;
|
||||
const client = new GitHubClient(token, repo, prNumber);
|
||||
|
||||
return { repo, prNumber, payload, client };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Execution
|
||||
// ============================================================================
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
options.token = options.token || envValue("GITHUB_TOKEN");
|
||||
|
||||
if (!options.dryRun && !options.token) {
|
||||
throw new Error("missing required GitHub token; set GITHUB_TOKEN or pass --token");
|
||||
}
|
||||
|
||||
const { repo, prNumber, payload, client } = await resolveContext(options);
|
||||
|
||||
const files = await client.listPrFiles();
|
||||
const classification = await classifyPr(payload, files);
|
||||
|
||||
if (options.dryRun) {
|
||||
printDryRunResult(formatDryRunResult(repo, prNumber, classification), options);
|
||||
return;
|
||||
}
|
||||
|
||||
const desired = new Set([classification.label]);
|
||||
for (const domain of classification.businessDomains) {
|
||||
desired.add(`domain/${domain}`);
|
||||
}
|
||||
|
||||
const current = await client.listIssueLabels();
|
||||
const managedCurrent = [...current].filter((label) => MANAGED_LABELS.has(label) || label.startsWith("domain/"));
|
||||
const toAdd = [...desired].filter((label) => !current.has(label)).sort();
|
||||
const toRemove = managedCurrent.filter((label) => !desired.has(label)).sort();
|
||||
|
||||
for (const domain of classification.businessDomains) {
|
||||
const labelName = `domain/${domain}`;
|
||||
if (!LABEL_DEFINITIONS[labelName]) {
|
||||
LABEL_DEFINITIONS[labelName] = { color: "1d76db", description: `PR touches the ${domain} domain` };
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure labels to be added actually exist in the repository first
|
||||
// If the label doesn't exist, GitHub API will return 422 Unprocessable Entity when trying to add it to a PR.
|
||||
for (const label of toAdd) {
|
||||
if (LABEL_DEFINITIONS[label]) {
|
||||
try {
|
||||
await client.syncLabelDefinition(label);
|
||||
} catch (e) {
|
||||
log(`Warning: Failed to bootstrap new label ${label}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await client.addLabels(toAdd);
|
||||
|
||||
for (const label of toRemove) {
|
||||
await client.removeLabel(label);
|
||||
}
|
||||
|
||||
// Keep other label metadata consistent. This is best-effort trailing work.
|
||||
for (const label of Object.keys(LABEL_DEFINITIONS)) {
|
||||
if (toAdd.includes(label)) continue; // Already synced above
|
||||
try {
|
||||
await client.syncLabelDefinition(label);
|
||||
} catch (e) {
|
||||
log(`Warning: Failed to sync label definition for ${label}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await writeStepSummary(prNumber, classification);
|
||||
|
||||
log(
|
||||
`pr #${prNumber} type=${classification.prType} total_changes=${classification.totalChanges} `
|
||||
+ `effective_changes=${classification.effectiveChanges} files=${files.length} `
|
||||
+ `desired=${[...desired].sort().join(",") || "-"} current_managed=${managedCurrent.sort().join(",") || "-"} `
|
||||
+ `reasons=${classification.reasons.join(" | ") || "-"}`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
log(error.message || String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
145
scripts/pr-labels/samples.json
Normal file
145
scripts/pr-labels/samples.json
Normal file
@@ -0,0 +1,145 @@
|
||||
[
|
||||
{
|
||||
"name": "size-s-docs-badge",
|
||||
"number": 103,
|
||||
"title": "docs: add official badge to distinguish from third-party Lark CLI tools",
|
||||
"pr_url": "https://github.com/larksuite/cli/pull/103",
|
||||
"status": "merged",
|
||||
"merged_at": "2026-03-30T12:15:45Z",
|
||||
"expected_label": "size/S",
|
||||
"expected_domains": [],
|
||||
"review_note": "Pure docs sample. Useful to confirm low-risk paths stay in S even when total changed lines are not tiny."
|
||||
},
|
||||
{
|
||||
"name": "size-s-docs-simplify",
|
||||
"number": 26,
|
||||
"title": "docs: simplify installation steps by merging CLI and Skills into one …",
|
||||
"pr_url": "https://github.com/larksuite/cli/pull/26",
|
||||
"status": "merged",
|
||||
"merged_at": "2026-03-28T09:33:24Z",
|
||||
"expected_label": "size/S",
|
||||
"expected_domains": [],
|
||||
"review_note": "Docs sample, verifying docs changes remain in S."
|
||||
},
|
||||
{
|
||||
"name": "size-s-docs-star-history",
|
||||
"number": 12,
|
||||
"title": "docs: add Star History chart to readmes",
|
||||
"pr_url": "https://github.com/larksuite/cli/pull/12",
|
||||
"status": "merged",
|
||||
"merged_at": "2026-03-28T16:00:15Z",
|
||||
"expected_label": "size/S",
|
||||
"expected_domains": [],
|
||||
"review_note": "Docs sample, no effective business code changes."
|
||||
},
|
||||
{
|
||||
"name": "size-s-docs-clarify-install",
|
||||
"number": 3,
|
||||
"title": "docs: clarify install methods and add source build steps",
|
||||
"pr_url": "https://github.com/larksuite/cli/pull/3",
|
||||
"status": "merged",
|
||||
"merged_at": "2026-03-28T03:43:44Z",
|
||||
"expected_label": "size/S",
|
||||
"expected_domains": [],
|
||||
"review_note": "Docs sample, pure documentation clarification."
|
||||
},
|
||||
{
|
||||
"name": "size-m-fix-base-scope",
|
||||
"number": 96,
|
||||
"title": "fix(base): correct scope for record history list shortcut",
|
||||
"pr_url": "https://github.com/larksuite/cli/pull/96",
|
||||
"status": "merged",
|
||||
"merged_at": "2026-03-30T11:40:18Z",
|
||||
"expected_label": "size/M",
|
||||
"expected_domains": ["domain/base"],
|
||||
"review_note": "Small fix sample. Verify the lower edge of the M bucket within a single domain."
|
||||
},
|
||||
{
|
||||
"name": "size-m-fix-mail-sensitive",
|
||||
"number": 92,
|
||||
"title": "fix: remove sensitive send scope from reply and forward shortcuts",
|
||||
"pr_url": "https://github.com/larksuite/cli/pull/92",
|
||||
"status": "merged",
|
||||
"merged_at": "2026-03-30T10:19:11Z",
|
||||
"expected_label": "size/M",
|
||||
"expected_domains": ["domain/mail"],
|
||||
"review_note": "Security-like wording in the title but stays in one business domain (mail)."
|
||||
},
|
||||
{
|
||||
"name": "size-m-ci-improve",
|
||||
"number": 71,
|
||||
"title": "ci: improve CI workflows and add golangci-lint config",
|
||||
"pr_url": "https://github.com/larksuite/cli/pull/71",
|
||||
"status": "merged",
|
||||
"merged_at": "2026-03-30T03:09:31Z",
|
||||
"expected_label": "size/M",
|
||||
"expected_domains": [],
|
||||
"review_note": "CI workflow change that goes beyond S threshold."
|
||||
},
|
||||
{
|
||||
"name": "size-m-feat-im-pagination",
|
||||
"number": 30,
|
||||
"title": "feat: add auto-pagination to messages search and update lark-im docs",
|
||||
"pr_url": "https://github.com/larksuite/cli/pull/30",
|
||||
"status": "merged",
|
||||
"merged_at": "2026-03-30T15:00:41Z",
|
||||
"expected_label": "size/M",
|
||||
"expected_domains": ["domain/im"],
|
||||
"review_note": "Single-domain feature with larger diff but effective changes stay in M."
|
||||
},
|
||||
{
|
||||
"name": "size-l-fix-api-silent",
|
||||
"number": 85,
|
||||
"title": "fix: resolve silent failure in `lark-cli api` error output (#39)",
|
||||
"pr_url": "https://github.com/larksuite/cli/pull/85",
|
||||
"status": "merged",
|
||||
"merged_at": "2026-03-30T09:19:24Z",
|
||||
"expected_label": "size/L",
|
||||
"expected_domains": [],
|
||||
"review_note": "Touches core area (cmd), bumping the size to L."
|
||||
},
|
||||
{
|
||||
"name": "size-l-fix-cli",
|
||||
"number": 91,
|
||||
"title": "fix: correct CLI examples in root help and READMEs (closes #48)",
|
||||
"pr_url": "https://github.com/larksuite/cli/pull/91",
|
||||
"status": "closed",
|
||||
"merged_at": null,
|
||||
"expected_label": "size/L",
|
||||
"expected_domains": [],
|
||||
"review_note": "Closed PR touching core area (cmd)."
|
||||
},
|
||||
{
|
||||
"name": "size-m-skill-format-check",
|
||||
"number": 134,
|
||||
"title": "feat(ci): add skill format check workflow to ensure SKILL.md compliance",
|
||||
"pr_url": "https://github.com/larksuite/cli/pull/134",
|
||||
"status": "closed",
|
||||
"merged_at": null,
|
||||
"expected_label": "size/M",
|
||||
"expected_domains": [],
|
||||
"review_note": "Includes updates to tests/bad-skill/SKILL.md inside skills-like paths, testing how skill mock files and test scripts are handled."
|
||||
},
|
||||
{
|
||||
"name": "size-l-ccm-multi-path",
|
||||
"number": 57,
|
||||
"title": "feat(docs): support local image upload in docs +create",
|
||||
"pr_url": "https://github.com/larksuite/cli/pull/57",
|
||||
"status": "closed",
|
||||
"merged_at": null,
|
||||
"expected_label": "size/L",
|
||||
"expected_domains": ["domain/ccm"],
|
||||
"review_note": "Touches docs_create_images.go and table_auto_width.go, representing multiple CCM sub-paths but resolving to a single ccm domain."
|
||||
},
|
||||
{
|
||||
"name": "size-l-domain-rename",
|
||||
"number": 11,
|
||||
"title": "docs: rename user-facing Bitable references to Base",
|
||||
"pr_url": "https://github.com/larksuite/cli/pull/11",
|
||||
"status": "merged",
|
||||
"merged_at": "2026-03-28T16:00:52Z",
|
||||
"expected_label": "size/L",
|
||||
"expected_domains": ["domain/base", "domain/ccm"],
|
||||
"review_note": "A rename across paths. Since we track previous_filename to evaluate domains, this should properly capture the base domain."
|
||||
}
|
||||
]
|
||||
52
scripts/pr-labels/test.js
Normal file
52
scripts/pr-labels/test.js
Normal file
@@ -0,0 +1,52 @@
|
||||
const fs = require('fs');
|
||||
const { execFileSync } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
const samplesPath = path.join(__dirname, 'samples.json');
|
||||
const indexPath = path.join(__dirname, 'index.js');
|
||||
const samples = JSON.parse(fs.readFileSync(samplesPath, 'utf8'));
|
||||
|
||||
if (!process.env.GITHUB_TOKEN) {
|
||||
console.error("❌ Error: GITHUB_TOKEN environment variable is required to run tests without hitting API rate limits.");
|
||||
console.error("Please run: GITHUB_TOKEN=$(gh auth token) node scripts/pr-labels/test.js");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const sample of samples) {
|
||||
try {
|
||||
const output = execFileSync(
|
||||
process.execPath,
|
||||
[indexPath, '--dry-run', '--json', '--pr-url', sample.pr_url],
|
||||
{ encoding: 'utf8', env: process.env }
|
||||
);
|
||||
const result = JSON.parse(output);
|
||||
|
||||
const matchLabel = result.label === sample.expected_label;
|
||||
|
||||
// Sort before comparing to ignore order
|
||||
const actualDomains = (result.businessDomains || []).sort();
|
||||
const expectedDomains = (sample.expected_domains || []).map(d => d.replace('domain/', '')).sort();
|
||||
|
||||
const matchDomains = JSON.stringify(actualDomains) === JSON.stringify(expectedDomains);
|
||||
|
||||
if (matchLabel && matchDomains) {
|
||||
console.log(`✅ Passed: ${sample.name}`);
|
||||
passed++;
|
||||
} else {
|
||||
console.log(`❌ Failed: ${sample.name}`);
|
||||
console.log(` Label expected: ${sample.expected_label}, got: ${result.label}`);
|
||||
console.log(` Domains expected: ${expectedDomains}, got: ${actualDomains}`);
|
||||
failed++;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`❌ Failed: ${sample.name} (Execution error)`);
|
||||
console.error(e.message);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nTest Summary: ${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
36
scripts/skill-format-check/README.md
Normal file
36
scripts/skill-format-check/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Skill Format Check
|
||||
|
||||
This directory contains a script to validate the format of `SKILL.md` files located in the `../../skills` directory.
|
||||
|
||||
## Purpose
|
||||
|
||||
The `index.js` script ensures that all `SKILL.md` files conform to the standard template defined in `skill-template/skill-template.md`. Specifically, it checks that the YAML frontmatter includes the following fields:
|
||||
- `name` (required)
|
||||
- `description` (required)
|
||||
- `metadata` (outputs a warning if missing, does not fail the build)
|
||||
|
||||
> **Note:** The `lark-shared` skill is explicitly excluded from these format checks.
|
||||
|
||||
## Usage
|
||||
|
||||
This script is executed automatically via GitHub Actions (`.github/workflows/skill-format-check.yml`) on pull requests and pushes that modify the `skills/` directory.
|
||||
|
||||
To run the check manually from the root of the repository, execute:
|
||||
|
||||
```bash
|
||||
node scripts/skill-format-check/index.js
|
||||
```
|
||||
|
||||
You can also specify a custom target directory as the first argument:
|
||||
|
||||
```bash
|
||||
node scripts/skill-format-check/index.js ./path/to/my/skills
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
This tool comes with a quick validation script to ensure it correctly identifies good and bad skill formats. To run the tests, execute:
|
||||
|
||||
```bash
|
||||
./scripts/skill-format-check/test.sh
|
||||
```
|
||||
96
scripts/skill-format-check/index.js
Normal file
96
scripts/skill-format-check/index.js
Normal file
@@ -0,0 +1,96 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Allow passing a target directory as the first argument.
|
||||
// If provided, resolve against process.cwd() so it behaves as the user expects.
|
||||
// If not provided, default to '../../skills' relative to this script's directory.
|
||||
const targetDirArg = process.argv[2];
|
||||
const SKILLS_DIR = targetDirArg
|
||||
? path.resolve(process.cwd(), targetDirArg)
|
||||
: path.resolve(__dirname, '../../skills');
|
||||
|
||||
function checkSkillFormat() {
|
||||
console.log(`Checking skill format in ${SKILLS_DIR}...`);
|
||||
|
||||
if (!fs.existsSync(SKILLS_DIR)) {
|
||||
console.error('Skills directory not found:', SKILLS_DIR);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let skills;
|
||||
try {
|
||||
skills = fs
|
||||
.readdirSync(SKILLS_DIR, { withFileTypes: true })
|
||||
.filter(entry => entry.isDirectory())
|
||||
.map(entry => entry.name);
|
||||
} catch (err) {
|
||||
console.error(`Failed to enumerate skills directory: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let hasErrors = false;
|
||||
|
||||
skills.forEach(skill => {
|
||||
// Skip lark-shared skill completely
|
||||
if (skill === 'lark-shared') {
|
||||
console.log(`⏭️ Skipping check for ${skill}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const skillPath = path.join(SKILLS_DIR, skill);
|
||||
const skillFile = path.join(skillPath, 'SKILL.md');
|
||||
|
||||
if (!fs.existsSync(skillFile)) {
|
||||
console.error(`❌ [${skill}] Missing SKILL.md`);
|
||||
hasErrors = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(skillFile, 'utf-8');
|
||||
} catch (err) {
|
||||
console.error(`❌ [${skill}] Failed to read SKILL.md: ${err.message}`);
|
||||
hasErrors = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize line endings to simplify parsing
|
||||
const normalizedContent = content.replace(/\r\n/g, '\n');
|
||||
|
||||
// Check YAML Frontmatter
|
||||
if (!normalizedContent.startsWith('---\n')) {
|
||||
console.error(`❌ [${skill}] SKILL.md must start with YAML frontmatter (---)`);
|
||||
hasErrors = true;
|
||||
} else {
|
||||
const frontmatterMatch = normalizedContent.match(/^---\n([\s\S]*?)\n---(?:\n|$)/);
|
||||
if (!frontmatterMatch) {
|
||||
console.error(`❌ [${skill}] SKILL.md has unclosed or invalid YAML frontmatter`);
|
||||
hasErrors = true;
|
||||
} else {
|
||||
const frontmatter = frontmatterMatch[1];
|
||||
if (!/^name:/m.test(frontmatter)) {
|
||||
console.error(`❌ [${skill}] YAML frontmatter missing 'name'`);
|
||||
hasErrors = true;
|
||||
}
|
||||
if (!/^description:/m.test(frontmatter)) {
|
||||
console.error(`❌ [${skill}] YAML frontmatter missing 'description'`);
|
||||
hasErrors = true;
|
||||
}
|
||||
if (!/^metadata:/m.test(frontmatter)) {
|
||||
console.warn(`⚠️ [${skill}] YAML frontmatter missing 'metadata' (Warning only)`);
|
||||
// hasErrors = true; // Downgrade to warning to not fail on existing skills
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (hasErrors) {
|
||||
console.error('\n❌ Skill format check failed. Please fix the errors above.');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('\n✅ Skill format check passed!');
|
||||
}
|
||||
}
|
||||
|
||||
checkSkillFormat();
|
||||
82
scripts/skill-format-check/test.sh
Executable file
82
scripts/skill-format-check/test.sh
Executable file
@@ -0,0 +1,82 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Get the directory of this script
|
||||
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
|
||||
INDEX_JS="$DIR/index.js"
|
||||
TEMP_DIR="$DIR/tests/temp_test_dir"
|
||||
|
||||
echo "=== Running tests for skill-format-check ==="
|
||||
echo "Index script: $INDEX_JS"
|
||||
|
||||
prepare_fixture() {
|
||||
local test_name=$1
|
||||
rm -rf "$TEMP_DIR"
|
||||
mkdir -p "$TEMP_DIR"
|
||||
if [ ! -d "$DIR/tests/$test_name" ]; then
|
||||
echo "❌ Missing fixture directory: $DIR/tests/$test_name"
|
||||
exit 1
|
||||
fi
|
||||
cp -r "$DIR/tests/$test_name" "$TEMP_DIR/" || {
|
||||
echo "❌ Failed to copy fixture: $test_name"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Function to run a positive test
|
||||
run_positive_test() {
|
||||
local test_name=$1
|
||||
echo -e "\n--- [Positive] $test_name ---"
|
||||
|
||||
prepare_fixture "$test_name"
|
||||
|
||||
node "$INDEX_JS" "$TEMP_DIR"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Passed! (Correctly validated $test_name)"
|
||||
rm -rf "$TEMP_DIR"
|
||||
return 0
|
||||
else
|
||||
echo "❌ Failed! Expected $test_name to pass but it failed."
|
||||
rm -rf "$TEMP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to run a negative test
|
||||
run_negative_test() {
|
||||
local test_name=$1
|
||||
echo -e "\n--- [Negative] $test_name ---"
|
||||
|
||||
prepare_fixture "$test_name"
|
||||
|
||||
# Capture output for diagnostics while still treating non-zero as expected
|
||||
local log_file="$TEMP_DIR/.validator.log"
|
||||
node "$INDEX_JS" "$TEMP_DIR" > "$log_file" 2>&1
|
||||
local exit_code=$?
|
||||
|
||||
if [ $exit_code -ne 0 ]; then
|
||||
echo "✅ Passed! (Correctly rejected $test_name)"
|
||||
rm -rf "$TEMP_DIR"
|
||||
return 0
|
||||
else
|
||||
echo "❌ Failed! Expected $test_name to fail but it passed."
|
||||
if [ -s "$log_file" ]; then
|
||||
echo "--- Validator output ---"
|
||||
cat "$log_file"
|
||||
fi
|
||||
rm -rf "$TEMP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Run positive tests
|
||||
run_positive_test "good-skill"
|
||||
run_positive_test "good-skill-minimal"
|
||||
run_positive_test "good-skill-complex"
|
||||
|
||||
# Run negative tests
|
||||
run_negative_test "bad-skill"
|
||||
run_negative_test "bad-skill-no-frontmatter"
|
||||
run_negative_test "bad-skill-unclosed-frontmatter"
|
||||
|
||||
echo -e "\n🎉 All tests passed successfully!"
|
||||
@@ -0,0 +1,3 @@
|
||||
# No Frontmatter Skill
|
||||
|
||||
This skill completely lacks a YAML frontmatter.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
name: bad-skill-unclosed
|
||||
version: 1.0.0
|
||||
description: "This skill has an unclosed frontmatter block."
|
||||
metadata: {}
|
||||
|
||||
# Unclosed Frontmatter Skill
|
||||
|
||||
This frontmatter does not have a closing `---` block.
|
||||
8
scripts/skill-format-check/tests/bad-skill/SKILL.md
Normal file
8
scripts/skill-format-check/tests/bad-skill/SKILL.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
version: 1.0.0
|
||||
metadata: {}
|
||||
---
|
||||
|
||||
# Bad Skill
|
||||
|
||||
This skill is missing required fields like name and description.
|
||||
17
scripts/skill-format-check/tests/good-skill-complex/SKILL.md
Normal file
17
scripts/skill-format-check/tests/good-skill-complex/SKILL.md
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: good-skill-complex
|
||||
version: 2.5.1-beta
|
||||
description: >
|
||||
A very complex description
|
||||
that spans multiple lines
|
||||
and contains weird chars: !@#$%^&*()
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli", "node"]
|
||||
cliHelp: "lark-cli something --help"
|
||||
customField: "customValue"
|
||||
---
|
||||
|
||||
# Complex Skill
|
||||
|
||||
This skill has a complex frontmatter block.
|
||||
10
scripts/skill-format-check/tests/good-skill-minimal/SKILL.md
Normal file
10
scripts/skill-format-check/tests/good-skill-minimal/SKILL.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: good-skill-minimal
|
||||
version: 0.1.0
|
||||
description: Minimal valid description
|
||||
metadata: {}
|
||||
---
|
||||
|
||||
# Minimal Skill
|
||||
|
||||
This has the bare minimum required fields.
|
||||
12
scripts/skill-format-check/tests/good-skill/SKILL.md
Normal file
12
scripts/skill-format-check/tests/good-skill/SKILL.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: good-skill
|
||||
version: 1.0.0
|
||||
description: "This is a properly formatted skill."
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
---
|
||||
|
||||
# Good Skill
|
||||
|
||||
This skill follows all the formatting rules.
|
||||
@@ -33,6 +33,8 @@ type RuntimeContext struct {
|
||||
Config *core.CliConfig
|
||||
Cmd *cobra.Command
|
||||
Format string
|
||||
JqExpr string // --jq expression; empty = no filter
|
||||
outputErr error // deferred error from Out()/OutFormat() jq filtering
|
||||
botOnly bool // set by framework for bot-only shortcuts
|
||||
resolvedAs core.Identity // effective identity resolved by framework
|
||||
Factory *cmdutil.Factory // injected by framework
|
||||
@@ -225,6 +227,20 @@ func (ctx *RuntimeContext) DoAPI(req *larkcore.ApiReq, opts ...larkcore.RequestO
|
||||
return ac.DoSDKRequest(ctx.ctx, req, ctx.As(), opts...)
|
||||
}
|
||||
|
||||
// DoAPIAsBot executes a raw Lark SDK request using bot identity (tenant access token),
|
||||
// regardless of the current --as flag. Use this for bot-only APIs (e.g. image/file upload)
|
||||
// that must be called with TAT even when the surrounding shortcut runs as user.
|
||||
func (ctx *RuntimeContext) DoAPIAsBot(req *larkcore.ApiReq, opts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
|
||||
ac, err := ctx.getAPIClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if optFn := cmdutil.ShortcutHeaderOpts(ctx.ctx); optFn != nil {
|
||||
opts = append(opts, optFn)
|
||||
}
|
||||
return ac.DoSDKRequest(ctx.ctx, req, core.AsBot, opts...)
|
||||
}
|
||||
|
||||
type cancelOnCloseReadCloser struct {
|
||||
io.ReadCloser
|
||||
cancel context.CancelFunc
|
||||
@@ -419,13 +435,27 @@ func (ctx *RuntimeContext) IO() *cmdutil.IOStreams {
|
||||
// Out prints a success JSON envelope to stdout.
|
||||
func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) {
|
||||
env := output.Envelope{OK: true, Identity: string(ctx.As()), Data: data, Meta: meta, Notice: output.GetNotice()}
|
||||
if ctx.JqExpr != "" {
|
||||
if err := output.JqFilter(ctx.IO().Out, env, ctx.JqExpr); err != nil {
|
||||
fmt.Fprintf(ctx.IO().ErrOut, "error: %v\n", err)
|
||||
if ctx.outputErr == nil {
|
||||
ctx.outputErr = err
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
b, _ := json.MarshalIndent(env, "", " ")
|
||||
fmt.Fprintln(ctx.IO().Out, string(b))
|
||||
}
|
||||
|
||||
// OutFormat prints output based on --format flag.
|
||||
// "json" (default) outputs JSON envelope; "pretty" calls prettyFn; others delegate to FormatValue.
|
||||
// When JqExpr is set, routes through Out() regardless of format.
|
||||
func (ctx *RuntimeContext) OutFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer)) {
|
||||
if ctx.JqExpr != "" {
|
||||
ctx.Out(data, meta)
|
||||
return
|
||||
}
|
||||
switch ctx.Format {
|
||||
case "pretty":
|
||||
if prettyFn != nil {
|
||||
@@ -546,6 +576,9 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo
|
||||
if err := validateEnumFlags(rctx, s.Flags); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := output.ValidateJqFlags(rctx.JqExpr, "", rctx.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.Validate != nil {
|
||||
if err := s.Validate(rctx.ctx, rctx); err != nil {
|
||||
return err
|
||||
@@ -562,7 +595,10 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo
|
||||
}
|
||||
}
|
||||
|
||||
return s.Execute(rctx.ctx, rctx)
|
||||
if err := s.Execute(rctx.ctx, rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return rctx.outputErr
|
||||
}
|
||||
|
||||
func resolveShortcutIdentity(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) (core.Identity, error) {
|
||||
@@ -604,6 +640,7 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
|
||||
if s.HasFormat {
|
||||
rctx.Format = rctx.Str("format")
|
||||
}
|
||||
rctx.JqExpr, _ = cmd.Flags().GetString("jq")
|
||||
return rctx, nil
|
||||
}
|
||||
|
||||
@@ -684,6 +721,7 @@ func registerShortcutFlags(cmd *cobra.Command, s *Shortcut) {
|
||||
if s.Risk == "high-risk-write" {
|
||||
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
|
||||
}
|
||||
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().String("as", s.AuthTypes[0], "identity type: user | bot")
|
||||
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
|
||||
201
shortcuts/common/runner_jq_test.go
Normal file
201
shortcuts/common/runner_jq_test.go
Normal file
@@ -0,0 +1,201 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// newJqTestContext creates a RuntimeContext wired for jq testing.
|
||||
func newJqTestContext(jqExpr, format string) (*RuntimeContext, *bytes.Buffer, *bytes.Buffer) {
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("jq", "", "")
|
||||
cmd.Flags().String("format", "json", "")
|
||||
cmd.Flags().String("as", "bot", "")
|
||||
cmd.ParseFlags(nil)
|
||||
if jqExpr != "" {
|
||||
cmd.Flags().Set("jq", jqExpr)
|
||||
}
|
||||
if format != "" {
|
||||
cmd.Flags().Set("format", format)
|
||||
}
|
||||
|
||||
rctx := &RuntimeContext{
|
||||
ctx: context.Background(),
|
||||
Config: &core.CliConfig{Brand: core.BrandFeishu},
|
||||
Cmd: cmd,
|
||||
Format: format,
|
||||
JqExpr: jqExpr,
|
||||
resolvedAs: core.AsBot,
|
||||
Factory: &cmdutil.Factory{
|
||||
IOStreams: &cmdutil.IOStreams{Out: stdout, ErrOut: stderr},
|
||||
},
|
||||
}
|
||||
return rctx, stdout, stderr
|
||||
}
|
||||
|
||||
func TestRuntimeContext_Out_WithJq(t *testing.T) {
|
||||
rctx, stdout, _ := newJqTestContext(".data.name", "")
|
||||
|
||||
rctx.Out(map[string]interface{}{
|
||||
"name": "Alice",
|
||||
"age": 30,
|
||||
}, nil)
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "Alice") {
|
||||
t.Errorf("expected jq-filtered 'Alice', got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "age") {
|
||||
t.Errorf("expected jq to filter out 'age', got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeContext_Out_WithJq_Identity(t *testing.T) {
|
||||
rctx, stdout, _ := newJqTestContext(".ok", "")
|
||||
|
||||
rctx.Out(map[string]interface{}{"key": "value"}, nil)
|
||||
|
||||
out := strings.TrimSpace(stdout.String())
|
||||
if out != "true" {
|
||||
t.Errorf("expected 'true' for .ok, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeContext_OutFormat_WithJq_OverridesFormat(t *testing.T) {
|
||||
rctx, stdout, _ := newJqTestContext(".data.items", "pretty")
|
||||
|
||||
items := []interface{}{"a", "b", "c"}
|
||||
rctx.OutFormat(map[string]interface{}{
|
||||
"items": items,
|
||||
}, nil, func(w io.Writer) {
|
||||
t.Error("prettyFn should not be called when jq is set")
|
||||
})
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "a") || !strings.Contains(out, "b") {
|
||||
t.Errorf("expected jq-filtered items, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeContext_Out_WithJq_InvalidExpr_WritesStderr(t *testing.T) {
|
||||
rctx, _, stderr := newJqTestContext(".foo | invalid_func_xyz", "")
|
||||
|
||||
rctx.Out(map[string]interface{}{"foo": "bar"}, nil)
|
||||
|
||||
if !strings.Contains(stderr.String(), "error") {
|
||||
t.Errorf("expected error on stderr for runtime jq error, got: %s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func newTestShortcutCmd(s *Shortcut) *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "test-shortcut"}
|
||||
cmd.SetContext(context.Background())
|
||||
registerShortcutFlags(cmd, s)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTestFactory() *cmdutil.Factory {
|
||||
return &cmdutil.Factory{
|
||||
Config: func() (*core.CliConfig, error) {
|
||||
return &core.CliConfig{
|
||||
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
|
||||
}, nil
|
||||
},
|
||||
LarkClient: func() (*lark.Client, error) {
|
||||
return lark.NewClient("test", "test"), nil
|
||||
},
|
||||
IOStreams: &cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShortcut_JqAndFormatConflict(t *testing.T) {
|
||||
s := &Shortcut{
|
||||
Service: "test",
|
||||
Command: "test-shortcut",
|
||||
AuthTypes: []string{"bot"},
|
||||
HasFormat: true,
|
||||
Execute: func(ctx context.Context, rctx *RuntimeContext) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd := newTestShortcutCmd(s)
|
||||
cmd.Flags().Set("jq", ".data")
|
||||
cmd.Flags().Set("format", "table")
|
||||
cmd.Flags().Set("as", "bot")
|
||||
|
||||
err := runShortcut(cmd, newTestFactory(), s, true)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --jq + --format table conflict")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShortcut_JqInvalidExpression(t *testing.T) {
|
||||
s := &Shortcut{
|
||||
Service: "test",
|
||||
Command: "test-shortcut",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(ctx context.Context, rctx *RuntimeContext) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd := newTestShortcutCmd(s)
|
||||
cmd.Flags().Set("jq", "invalid[")
|
||||
cmd.Flags().Set("as", "bot")
|
||||
|
||||
err := runShortcut(cmd, newTestFactory(), s, true)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid jq expression")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid jq expression") {
|
||||
t.Errorf("expected 'invalid jq expression' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShortcut_JqRuntimeError_PropagatesError(t *testing.T) {
|
||||
s := &Shortcut{
|
||||
Service: "test",
|
||||
Command: "test-shortcut",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(ctx context.Context, rctx *RuntimeContext) error {
|
||||
rctx.Out(map[string]interface{}{"foo": "bar"}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd := newTestShortcutCmd(s)
|
||||
cmd.Flags().Set("jq", ".foo | invalid_func_xyz")
|
||||
cmd.Flags().Set("as", "bot")
|
||||
|
||||
err := runShortcut(cmd, newTestFactory(), s, true)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from jq runtime failure to propagate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeContext_Out_WithoutJq_NormalOutput(t *testing.T) {
|
||||
rctx, stdout, _ := newJqTestContext("", "")
|
||||
|
||||
rctx.Out(map[string]interface{}{"key": "value"}, &output.Meta{Count: 1})
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"ok"`) || !strings.Contains(out, `"key"`) {
|
||||
t.Errorf("expected normal JSON envelope, got: %s", out)
|
||||
}
|
||||
}
|
||||
245
shortcuts/drive/drive_export.go
Normal file
245
shortcuts/drive/drive_export.go
Normal file
@@ -0,0 +1,245 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// DriveExport exports Drive-native documents to local files and falls back to
|
||||
// a follow-up command when the async export task does not finish in time.
|
||||
var DriveExport = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+export",
|
||||
Description: "Export a doc/docx/sheet/bitable to a local file with limited polling",
|
||||
Risk: "read",
|
||||
Scopes: []string{
|
||||
"docs:document.content:read",
|
||||
"docs:document:export",
|
||||
"drive:drive.metadata:readonly",
|
||||
},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "source document token", Required: true},
|
||||
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable", Required: true, Enum: []string{"doc", "docx", "sheet", "bitable"}},
|
||||
{Name: "file-extension", Desc: "export format: docx | pdf | xlsx | csv | markdown", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown"}},
|
||||
{Name: "sub-id", Desc: "sub-table/sheet ID, required when exporting sheet/bitable as csv"},
|
||||
{Name: "output-dir", Default: ".", Desc: "local output directory (default: current directory)"},
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveExportSpec(driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
})
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
}
|
||||
// Markdown export is a special case: docx markdown comes from docs content
|
||||
// directly instead of the Drive export task API.
|
||||
if spec.FileExtension == "markdown" {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: fetch docx markdown -> write local file").
|
||||
GET("/open-apis/docs/v1/content").
|
||||
Params(map[string]interface{}{
|
||||
"doc_token": spec.Token,
|
||||
"doc_type": "docx",
|
||||
"content_type": "markdown",
|
||||
})
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
Desc("3-step orchestration: create export task -> limited polling -> download file").
|
||||
POST("/open-apis/drive/v1/export_tasks").
|
||||
Body(body)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
}
|
||||
outputDir := runtime.Str("output-dir")
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
|
||||
// Markdown export bypasses the async export task and writes the fetched
|
||||
// markdown content directly to disk.
|
||||
if spec.FileExtension == "markdown" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
|
||||
data, err := runtime.CallAPI(
|
||||
"GET",
|
||||
"/open-apis/docs/v1/content",
|
||||
map[string]interface{}{
|
||||
"doc_token": spec.Token,
|
||||
"doc_type": "docx",
|
||||
"content_type": "markdown",
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Prefer the remote title for the exported file name, but still fall
|
||||
// back to the token if metadata is empty.
|
||||
title, err := fetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
|
||||
title = spec.Token
|
||||
}
|
||||
fileName := ensureExportFileExtension(sanitizeExportFileName(title, spec.Token), spec.FileExtension)
|
||||
savedPath, err := saveContentToOutputDir(outputDir, fileName, []byte(common.GetString(data, "content")), overwrite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_name": filepath.Base(savedPath),
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": len([]byte(common.GetString(data, "content"))),
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
ticket, err := createDriveExportTask(runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
|
||||
|
||||
var lastStatus driveExportStatus
|
||||
var lastPollErr error
|
||||
hasObservedStatus := false
|
||||
// Keep the command responsive by polling for a bounded window. If the task
|
||||
// is still running after that, return a resume command instead of blocking.
|
||||
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(driveExportPollInterval):
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
|
||||
if err != nil {
|
||||
// Treat polling failures as transient so short-lived backend hiccups
|
||||
// do not immediately fail an otherwise healthy export task.
|
||||
lastPollErr = err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
hasObservedStatus = true
|
||||
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
|
||||
fileName := ensureExportFileExtension(sanitizeExportFileName(status.FileName, spec.Token), spec.FileExtension)
|
||||
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
|
||||
if err != nil {
|
||||
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
|
||||
hint := fmt.Sprintf(
|
||||
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
|
||||
ticket,
|
||||
status.FileToken,
|
||||
recoveryCommand,
|
||||
)
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
return output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint)
|
||||
}
|
||||
return output.ErrWithHint(output.ExitAPI, "api_error", err.Error(), hint)
|
||||
}
|
||||
out["ticket"] = ticket
|
||||
out["doc_type"] = spec.DocType
|
||||
out["file_extension"] = spec.FileExtension
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
if status.Failed() {
|
||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
}
|
||||
return output.Errorf(output.ExitAPI, "api_error", "export task failed: %s (ticket=%s)", msg, ticket)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
|
||||
}
|
||||
|
||||
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
|
||||
if !hasObservedStatus && lastPollErr != nil {
|
||||
hint := fmt.Sprintf(
|
||||
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
|
||||
ticket,
|
||||
nextCommand,
|
||||
)
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(lastPollErr, &exitErr) && exitErr.Detail != nil {
|
||||
if strings.TrimSpace(exitErr.Detail.Hint) != "" {
|
||||
hint = exitErr.Detail.Hint + "\n" + hint
|
||||
}
|
||||
return output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint)
|
||||
}
|
||||
return output.ErrWithHint(output.ExitAPI, "api_error", lastPollErr.Error(), hint)
|
||||
}
|
||||
|
||||
failed := false
|
||||
var jobStatus interface{}
|
||||
jobStatusLabel := "unknown"
|
||||
if hasObservedStatus {
|
||||
failed = lastStatus.Failed()
|
||||
jobStatus = lastStatus.JobStatus
|
||||
jobStatusLabel = lastStatus.StatusLabel()
|
||||
}
|
||||
// Return the last observed status so callers can resume from a known task
|
||||
// state instead of losing all progress information on timeout.
|
||||
runtime.Out(map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"ready": false,
|
||||
"failed": failed,
|
||||
"job_status": jobStatus,
|
||||
"job_status_label": jobStatusLabel,
|
||||
"timed_out": true,
|
||||
"next_command": nextCommand,
|
||||
}, nil)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
371
shortcuts/drive/drive_export_common.go
Normal file
371
shortcuts/drive/drive_export_common.go
Normal file
@@ -0,0 +1,371 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var (
|
||||
driveExportPollAttempts = 10
|
||||
driveExportPollInterval = 5 * time.Second
|
||||
)
|
||||
|
||||
// driveExportSpec contains the normalized export request understood by the
|
||||
// shortcut and the underlying export task APIs.
|
||||
type driveExportSpec struct {
|
||||
Token string
|
||||
DocType string
|
||||
FileExtension string
|
||||
SubID string
|
||||
}
|
||||
|
||||
// driveExportTaskResultCommand prints the resume command shown when bounded
|
||||
// export polling times out locally.
|
||||
func driveExportTaskResultCommand(ticket, docToken string) string {
|
||||
return fmt.Sprintf("lark-cli drive +task_result --scenario export --ticket %s --file-token %s", ticket, docToken)
|
||||
}
|
||||
|
||||
// driveExportDownloadCommand prints a copy-pasteable follow-up command for
|
||||
// downloading an already-generated export artifact by file token.
|
||||
func driveExportDownloadCommand(fileToken, fileName, outputDir string, overwrite bool) string {
|
||||
parts := []string{
|
||||
"lark-cli", "drive", "+export-download",
|
||||
"--file-token", strconv.Quote(fileToken),
|
||||
}
|
||||
if strings.TrimSpace(fileName) != "" {
|
||||
parts = append(parts, "--file-name", strconv.Quote(fileName))
|
||||
}
|
||||
if strings.TrimSpace(outputDir) != "" && outputDir != "." {
|
||||
parts = append(parts, "--output-dir", strconv.Quote(outputDir))
|
||||
}
|
||||
if overwrite {
|
||||
parts = append(parts, "--overwrite")
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// driveExportStatus captures the fields needed to decide whether the export is
|
||||
// ready for download, still pending, or terminally failed.
|
||||
type driveExportStatus struct {
|
||||
Ticket string
|
||||
FileExtension string
|
||||
DocType string
|
||||
FileName string
|
||||
FileToken string
|
||||
JobErrorMsg string
|
||||
FileSize int64
|
||||
JobStatus int
|
||||
}
|
||||
|
||||
func (s driveExportStatus) Ready() bool {
|
||||
return s.FileToken != "" && s.JobStatus == 0
|
||||
}
|
||||
|
||||
func (s driveExportStatus) Pending() bool {
|
||||
// A zero status without a file token is still in progress because there is
|
||||
// nothing downloadable yet.
|
||||
return s.JobStatus == 1 || s.JobStatus == 2 || s.JobStatus == 0 && s.FileToken == ""
|
||||
}
|
||||
|
||||
func (s driveExportStatus) Failed() bool {
|
||||
return !s.Ready() && !s.Pending() && s.JobStatus != 0
|
||||
}
|
||||
|
||||
func (s driveExportStatus) StatusLabel() string {
|
||||
switch s.JobStatus {
|
||||
case 0:
|
||||
// Success is a special case where the file token is set.
|
||||
if s.FileToken != "" {
|
||||
return "success"
|
||||
}
|
||||
return "pending"
|
||||
case 1:
|
||||
return "new"
|
||||
case 2:
|
||||
return "processing"
|
||||
case 3:
|
||||
return "internal_error"
|
||||
case 107:
|
||||
return "export_size_limit"
|
||||
case 108:
|
||||
return "timeout"
|
||||
case 109:
|
||||
return "export_block_not_permitted"
|
||||
case 110:
|
||||
return "no_permission"
|
||||
case 111:
|
||||
return "docs_deleted"
|
||||
case 122:
|
||||
return "export_denied_on_copying"
|
||||
case 123:
|
||||
return "docs_not_exist"
|
||||
case 6000:
|
||||
return "export_images_exceed_limit"
|
||||
default:
|
||||
return fmt.Sprintf("status_%d", s.JobStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// validateDriveExportSpec enforces shortcut-level export constraints before any
|
||||
// backend request is sent.
|
||||
func validateDriveExportSpec(spec driveExportSpec) error {
|
||||
if err := validate.ResourceName(spec.Token, "--token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
|
||||
switch spec.DocType {
|
||||
case "doc", "docx", "sheet", "bitable":
|
||||
default:
|
||||
return output.ErrValidation("invalid --doc-type %q: allowed values are doc, docx, sheet, bitable", spec.DocType)
|
||||
}
|
||||
|
||||
switch spec.FileExtension {
|
||||
case "docx", "pdf", "xlsx", "csv", "markdown":
|
||||
default:
|
||||
return output.ErrValidation("invalid --file-extension %q: allowed values are docx, pdf, xlsx, csv, markdown", spec.FileExtension)
|
||||
}
|
||||
|
||||
if spec.FileExtension == "markdown" && spec.DocType != "docx" {
|
||||
return output.ErrValidation("--file-extension markdown only supports --doc-type docx")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
if spec.FileExtension != "csv" || (spec.DocType != "sheet" && spec.DocType != "bitable") {
|
||||
return output.ErrValidation("--sub-id is only used when exporting sheet/bitable as csv")
|
||||
}
|
||||
if err := validate.ResourceName(spec.SubID, "--sub-id"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
|
||||
if spec.FileExtension == "csv" && (spec.DocType == "sheet" || spec.DocType == "bitable") && strings.TrimSpace(spec.SubID) == "" {
|
||||
return output.ErrValidation("--sub-id is required when exporting sheet/bitable as csv")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createDriveExportTask starts the asynchronous export job and returns its
|
||||
// ticket for subsequent polling.
|
||||
func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec) (string, error) {
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ticket := common.GetString(data, "ticket")
|
||||
if ticket == "" {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "export task created but ticket is missing")
|
||||
}
|
||||
return ticket, nil
|
||||
}
|
||||
|
||||
// getDriveExportStatus fetches the current backend state for a previously
|
||||
// created export task.
|
||||
func getDriveExportStatus(runtime *common.RuntimeContext, token, ticket string) (driveExportStatus, error) {
|
||||
data, err := runtime.CallAPI(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/drive/v1/export_tasks/%s", validate.EncodePathSegment(ticket)),
|
||||
map[string]interface{}{"token": token},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return driveExportStatus{}, err
|
||||
}
|
||||
return parseDriveExportStatus(ticket, data), nil
|
||||
}
|
||||
|
||||
// parseDriveExportStatus accepts the wrapped export result and normalizes the
|
||||
// subset of fields used by the shortcut.
|
||||
func parseDriveExportStatus(ticket string, data map[string]interface{}) driveExportStatus {
|
||||
result := common.GetMap(data, "result")
|
||||
status := driveExportStatus{
|
||||
Ticket: ticket,
|
||||
}
|
||||
if result == nil {
|
||||
// Keep the ticket even when the result body is missing so callers can
|
||||
// still show a resumable task reference.
|
||||
return status
|
||||
}
|
||||
|
||||
status.FileExtension = common.GetString(result, "file_extension")
|
||||
status.DocType = common.GetString(result, "type")
|
||||
status.FileName = common.GetString(result, "file_name")
|
||||
status.FileToken = common.GetString(result, "file_token")
|
||||
status.JobErrorMsg = common.GetString(result, "job_error_msg")
|
||||
status.FileSize = int64(common.GetFloat(result, "file_size"))
|
||||
status.JobStatus = int(common.GetFloat(result, "job_status"))
|
||||
return status
|
||||
}
|
||||
|
||||
// fetchDriveMetaTitle looks up the document title so exported files can use a
|
||||
// human-readable default name when possible.
|
||||
func fetchDriveMetaTitle(runtime *common.RuntimeContext, token, docType string) (string, error) {
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/metas/batch_query",
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": token,
|
||||
"doc_type": docType,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
metas := common.GetSlice(data, "metas")
|
||||
if len(metas) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
meta, _ := metas[0].(map[string]interface{})
|
||||
return common.GetString(meta, "title"), nil
|
||||
}
|
||||
|
||||
// saveContentToOutputDir validates the target path, enforces overwrite policy,
|
||||
// and writes the payload atomically to disk.
|
||||
func saveContentToOutputDir(outputDir, fileName string, payload []byte, overwrite bool) (string, error) {
|
||||
if outputDir == "" {
|
||||
outputDir = "."
|
||||
}
|
||||
|
||||
// Sanitize both the filename and the combined output path so caller-provided
|
||||
// names cannot escape the requested output directory.
|
||||
safeName := sanitizeExportFileName(fileName, "export.bin")
|
||||
target := filepath.Join(outputDir, safeName)
|
||||
safePath, err := validate.SafeOutputPath(target)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
if err := common.EnsureWritableFile(safePath, overwrite); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(safePath), 0755); err != nil {
|
||||
return "", output.Errorf(output.ExitInternal, "io", "cannot create output directory: %s", err)
|
||||
}
|
||||
if err := validate.AtomicWrite(safePath, payload, 0644); err != nil {
|
||||
return "", output.Errorf(output.ExitInternal, "io", "cannot write file: %s", err)
|
||||
}
|
||||
return safePath, nil
|
||||
}
|
||||
|
||||
// downloadDriveExportFile downloads the exported artifact, derives a safe local
|
||||
// file name, and returns metadata about the saved file.
|
||||
func downloadDriveExportFile(ctx context.Context, runtime *common.RuntimeContext, fileToken, outputDir, preferredName string, overwrite bool) (map[string]interface{}, error) {
|
||||
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
|
||||
return nil, output.ErrValidation("%s", err)
|
||||
}
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
}, larkcore.WithFileDownload())
|
||||
if err != nil {
|
||||
return nil, output.ErrNetwork("download failed: %s", err)
|
||||
}
|
||||
if apiResp.StatusCode >= 400 {
|
||||
return nil, output.ErrNetwork("download failed: HTTP %d: %s", apiResp.StatusCode, string(apiResp.RawBody))
|
||||
}
|
||||
|
||||
fileName := strings.TrimSpace(preferredName)
|
||||
if fileName == "" {
|
||||
// Fall back to the server-provided download name when the caller did not
|
||||
// request an explicit local file name.
|
||||
fileName = client.ResolveFilename(apiResp)
|
||||
}
|
||||
savedPath, err := saveContentToOutputDir(outputDir, fileName, apiResp.RawBody, overwrite)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
"file_name": filepath.Base(savedPath),
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": len(apiResp.RawBody),
|
||||
"content_type": apiResp.Header.Get("Content-Type"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// sanitizeExportFileName strips path traversal and unsupported characters while
|
||||
// preserving a readable file name when possible.
|
||||
func sanitizeExportFileName(name, fallback string) string {
|
||||
name = strings.TrimSpace(filepath.Base(name))
|
||||
if name == "" || name == "." || name == string(filepath.Separator) {
|
||||
name = fallback
|
||||
}
|
||||
|
||||
replacer := strings.NewReplacer(
|
||||
"/", "_", "\\", "_", ":", "_", "*", "_", "?", "_",
|
||||
"\"", "_", "<", "_", ">", "_", "|", "_",
|
||||
"\n", "_", "\r", "_", "\t", "_", "\x00", "_",
|
||||
)
|
||||
name = replacer.Replace(name)
|
||||
name = strings.Trim(name, ". ")
|
||||
if name == "" {
|
||||
return fallback
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// ensureExportFileExtension appends the expected local suffix when the chosen
|
||||
// file name does not already end with the export format's extension.
|
||||
func ensureExportFileExtension(name, fileExtension string) string {
|
||||
expected := exportFileSuffix(fileExtension)
|
||||
if expected == "" {
|
||||
return name
|
||||
}
|
||||
if strings.EqualFold(filepath.Ext(name), expected) {
|
||||
return name
|
||||
}
|
||||
return name + expected
|
||||
}
|
||||
|
||||
// exportFileSuffix maps shortcut-level export formats to the local filename
|
||||
// suffix written to disk.
|
||||
func exportFileSuffix(fileExtension string) string {
|
||||
switch fileExtension {
|
||||
case "markdown":
|
||||
return ".md"
|
||||
case "docx":
|
||||
return ".docx"
|
||||
case "pdf":
|
||||
return ".pdf"
|
||||
case "xlsx":
|
||||
return ".xlsx"
|
||||
case "csv":
|
||||
return ".csv"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
67
shortcuts/drive/drive_export_common_test.go
Normal file
67
shortcuts/drive/drive_export_common_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDriveExportStatusLabelCoversKnownAndUnknownCodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
status driveExportStatus
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "size limit",
|
||||
status: driveExportStatus{JobStatus: 107},
|
||||
want: "export_size_limit",
|
||||
},
|
||||
{
|
||||
name: "not exist",
|
||||
status: driveExportStatus{JobStatus: 123},
|
||||
want: "docs_not_exist",
|
||||
},
|
||||
{
|
||||
name: "unknown status",
|
||||
status: driveExportStatus{JobStatus: 999},
|
||||
want: "status_999",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := tt.status.StatusLabel(); got != tt.want {
|
||||
t.Fatalf("StatusLabel() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDriveExportStatusWithoutResultKeepsTicket(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
status := parseDriveExportStatus("ticket_export_test", map[string]interface{}{})
|
||||
if status.Ticket != "ticket_export_test" {
|
||||
t.Fatalf("ticket = %q, want %q", status.Ticket, "ticket_export_test")
|
||||
}
|
||||
if status.FileToken != "" {
|
||||
t.Fatalf("file token = %q, want empty", status.FileToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeExportFileNameAndEnsureExtension(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := sanitizeExportFileName("../quarterly:report?.pdf", "fallback.bin"); got != "quarterly_report_.pdf" {
|
||||
t.Fatalf("sanitizeExportFileName() = %q, want %q", got, "quarterly_report_.pdf")
|
||||
}
|
||||
if got := ensureExportFileExtension("meeting-notes", "markdown"); got != "meeting-notes.md" {
|
||||
t.Fatalf("ensureExportFileExtension() = %q, want %q", got, "meeting-notes.md")
|
||||
}
|
||||
if got := ensureExportFileExtension("report.pdf", "pdf"); got != "report.pdf" {
|
||||
t.Fatalf("ensureExportFileExtension() should preserve suffix, got %q", got)
|
||||
}
|
||||
}
|
||||
60
shortcuts/drive/drive_export_download.go
Normal file
60
shortcuts/drive/drive_export_download.go
Normal file
@@ -0,0 +1,60 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// DriveExportDownload downloads an already-generated export artifact when the
|
||||
// caller has a file token from a previous export task.
|
||||
var DriveExportDownload = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+export-download",
|
||||
Description: "Download an exported file by file_token",
|
||||
Risk: "read",
|
||||
Scopes: []string{
|
||||
"docs:document:export",
|
||||
},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "exported file token", Required: true},
|
||||
{Name: "file-name", Desc: "preferred output filename (optional)"},
|
||||
{Name: "output-dir", Default: ".", Desc: "local output directory (default: current directory)"},
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validate.ResourceName(runtime.Str("file-token"), "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/drive/v1/export_tasks/file/:file_token/download").
|
||||
Set("file_token", runtime.Str("file-token")).
|
||||
Set("output_dir", runtime.Str("output-dir"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// Reuse the shared export download helper so overwrite checks, filename
|
||||
// resolution, and output metadata stay consistent with drive +export.
|
||||
out, err := downloadDriveExportFile(
|
||||
ctx,
|
||||
runtime,
|
||||
runtime.Str("file-token"),
|
||||
runtime.Str("output-dir"),
|
||||
runtime.Str("file-name"),
|
||||
runtime.Bool("overwrite"),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
516
shortcuts/drive/drive_export_test.go
Normal file
516
shortcuts/drive/drive_export_test.go
Normal file
@@ -0,0 +1,516 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestValidateDriveExportSpec(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
spec driveExportSpec
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "markdown docx ok",
|
||||
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "markdown"},
|
||||
},
|
||||
{
|
||||
name: "markdown non docx rejected",
|
||||
spec: driveExportSpec{Token: "doc123", DocType: "doc", FileExtension: "markdown"},
|
||||
wantErr: "only supports --doc-type docx",
|
||||
},
|
||||
{
|
||||
name: "csv without sub id rejected",
|
||||
spec: driveExportSpec{Token: "sheet123", DocType: "sheet", FileExtension: "csv"},
|
||||
wantErr: "--sub-id is required",
|
||||
},
|
||||
{
|
||||
name: "sub id on non csv rejected",
|
||||
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "pdf", SubID: "tbl_1"},
|
||||
wantErr: "--sub-id is only used",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := validateDriveExportSpec(tt.spec)
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportMarkdownWritesFile(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs/v1/content",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"content": "# hello\n",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"title": "Weekly Notes"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "markdown",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "Weekly Notes.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "# hello\n" {
|
||||
t.Fatalf("markdown content = %q", string(data))
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Weekly Notes.md") {
|
||||
t.Fatalf("stdout missing file name: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_123"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_123",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0,
|
||||
"file_token": "box_123",
|
||||
"file_name": "report",
|
||||
"file_extension": "pdf",
|
||||
"type": "docx",
|
||||
"file_size": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_123/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="report.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "report.pdf"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "pdf" {
|
||||
t.Fatalf("downloaded content = %q", string(data))
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"ticket": "tk_123"`) {
|
||||
t.Fatalf("stdout missing ticket: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportReadyDownloadFailureIncludesRecoveryHint(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_ready"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_ready",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0,
|
||||
"file_token": "box_ready",
|
||||
"file_name": "report",
|
||||
"file_extension": "pdf",
|
||||
"type": "docx",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_ready/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="report.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "report.pdf"), []byte("old"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected download recovery error, got nil")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "already exists") {
|
||||
t.Fatalf("message missing overwrite guidance: %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "ticket=tk_ready") {
|
||||
t.Fatalf("hint missing ticket: %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "file_token=box_ready") {
|
||||
t.Fatalf("hint missing file token: %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, `lark-cli drive +export-download --file-token "box_ready" --file-name "report.pdf"`) {
|
||||
t.Fatalf("hint missing recovery command: %q", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportTimeoutReturnsFollowUpCommand(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_456"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_456",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"ticket": "tk_456"`) {
|
||||
t.Fatalf("stdout missing ticket: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"timed_out": true`) {
|
||||
t.Fatalf("stdout missing timed_out=true: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"failed": false`) {
|
||||
t.Fatalf("stdout missing failed=false: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"job_status": 2`) {
|
||||
t.Fatalf("stdout missing numeric job_status: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"job_status_label": "processing"`) {
|
||||
t.Fatalf("stdout missing processing job_status_label: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"next_command": "lark-cli drive +task_result --scenario export --ticket tk_456 --file-token docx123"`) {
|
||||
t.Fatalf("stdout missing follow-up command: %s", stdout.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "report.pdf")); !os.IsNotExist(err) {
|
||||
t.Fatalf("unexpected downloaded file, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportPollErrorsReturnLastErrorWithRecoveryHint(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_poll_fail"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_poll_fail",
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{
|
||||
"code": 999,
|
||||
"msg": "temporary backend failure",
|
||||
},
|
||||
})
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected persistent poll error, got nil")
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout should stay empty on persistent poll error: %s", stdout.String())
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "temporary backend failure") {
|
||||
t.Fatalf("message missing last poll error: %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "ticket=tk_poll_fail") {
|
||||
t.Fatalf("hint missing ticket: %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "lark-cli drive +task_result --scenario export --ticket tk_poll_fail --file-token docx123") {
|
||||
t.Fatalf("hint missing recovery command: %q", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportDownloadUsesProvidedFileName(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_789/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("csv"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"text/csv"},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveExportDownload, []string{
|
||||
"+export-download",
|
||||
"--file-token", "box_789",
|
||||
"--file-name", "custom.csv",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "custom.csv"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "csv" {
|
||||
t.Fatalf("downloaded content = %q", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportDownloadRejectsOverwriteWithoutFlag(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_dup/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("new"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="dup.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile("dup.pdf", []byte("old"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunDrive(t, DriveExportDownload, []string{
|
||||
"+export-download",
|
||||
"--file-token", "box_dup",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected overwrite protection error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "already exists") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveContentToOutputDirRejectsOverwriteWithoutFlag(t *testing.T) {
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
target := filepath.Join(tmpDir, "exists.txt")
|
||||
if err := os.WriteFile(target, []byte("old"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Getwd() error: %v", err)
|
||||
}
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("Chdir() error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
||||
|
||||
_, err = saveContentToOutputDir(".", "exists.txt", []byte("new"), false)
|
||||
if err == nil || !strings.Contains(err.Error(), "already exists") {
|
||||
t.Fatalf("expected overwrite error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveTaskResultExportIncludesReadyFlags(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_export",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveTaskResult, []string{
|
||||
"+task_result",
|
||||
"--scenario", "export",
|
||||
"--ticket", "tk_export",
|
||||
"--file-token", "docx123",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) {
|
||||
t.Fatalf("stdout missing ready=false: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"failed": false`)) {
|
||||
t.Fatalf("stdout missing failed=false: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"job_status_label": "processing"`)) {
|
||||
t.Fatalf("stdout missing job_status_label: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
229
shortcuts/drive/drive_import.go
Normal file
229
shortcuts/drive/drive_import.go
Normal file
@@ -0,0 +1,229 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// DriveImport uploads a local file, creates an import task, and polls until
|
||||
// the imported cloud document is ready or the local polling window expires.
|
||||
var DriveImport = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+import",
|
||||
Description: "Import a local file to Drive as a cloud document (docx, sheet, bitable)",
|
||||
Risk: "write",
|
||||
Scopes: []string{
|
||||
"docs:document.media:upload",
|
||||
"docs:document:import",
|
||||
},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file", Desc: "local file path (e.g. .docx, .xlsx, .md; large files auto use multipart upload)", Required: true},
|
||||
{Name: "type", Desc: "target document type (docx, sheet, bitable)", Required: true},
|
||||
{Name: "folder-token", Desc: "target folder token (omit for root folder; API accepts empty mount_key as root)"},
|
||||
{Name: "name", Desc: "imported file name (default: local file name without extension)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveImportSpec(driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
})
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
}
|
||||
fileSize, err := preflightDriveImportFile(&spec)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
|
||||
|
||||
appendDriveImportUploadDryRun(dry, spec, fileSize)
|
||||
|
||||
dry.POST("/open-apis/drive/v1/import_tasks").
|
||||
Desc("[2] Create import task").
|
||||
Body(spec.CreateTaskBody("<file_token>"))
|
||||
|
||||
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
|
||||
Desc("[3] Poll import task result").
|
||||
Set("ticket", "<ticket>")
|
||||
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
}
|
||||
if _, err := preflightDriveImportFile(&spec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 1: Upload file as media
|
||||
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
|
||||
if uploadErr != nil {
|
||||
return uploadErr
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
|
||||
|
||||
// Step 2: Create import task
|
||||
ticket, err := createDriveImportTask(runtime, spec, fileToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3: Poll task
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
|
||||
|
||||
status, ready, err := pollDriveImportTask(runtime, ticket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Some intermediate responses omit the final type, so fall back to the
|
||||
// requested type to keep the output shape stable.
|
||||
resultType := status.DocType
|
||||
if resultType == "" {
|
||||
resultType = spec.DocType
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"type": resultType,
|
||||
"ready": ready,
|
||||
"job_status": status.JobStatus,
|
||||
"job_status_label": status.StatusLabel(),
|
||||
}
|
||||
if status.Token != "" {
|
||||
out["token"] = status.Token
|
||||
}
|
||||
if status.URL != "" {
|
||||
out["url"] = status.URL
|
||||
}
|
||||
if status.JobErrorMsg != "" {
|
||||
out["job_error_msg"] = status.JobErrorMsg
|
||||
}
|
||||
if status.Extra != nil {
|
||||
out["extra"] = status.Extra
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := driveImportTaskResultCommand(ticket)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func preflightDriveImportFile(spec *driveImportSpec) (int64, error) {
|
||||
// Keep dry-run and execution aligned on path normalization, file existence,
|
||||
// and format-specific size limits before planning the upload path.
|
||||
safeFilePath, err := validate.SafeInputPath(spec.FilePath)
|
||||
if err != nil {
|
||||
return 0, output.ErrValidation("unsafe file path: %s", err)
|
||||
}
|
||||
spec.FilePath = safeFilePath
|
||||
|
||||
info, err := os.Stat(spec.FilePath)
|
||||
if err != nil {
|
||||
return 0, output.ErrValidation("cannot read file: %s", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return 0, output.ErrValidation("file must be a regular file: %s", spec.FilePath)
|
||||
}
|
||||
if err = validateDriveImportFileSize(spec.FilePath, spec.DocType, info.Size()); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return info.Size(), nil
|
||||
}
|
||||
|
||||
func appendDriveImportUploadDryRun(dry *common.DryRunAPI, spec driveImportSpec, fileSize int64) {
|
||||
extra, err := buildImportMediaExtra(spec.FilePath, spec.DocType)
|
||||
if err != nil {
|
||||
extra = fmt.Sprintf(`{"obj_type":"%s","file_extension":"%s"}`, spec.DocType, spec.FileExtension())
|
||||
}
|
||||
|
||||
if fileSize > maxDriveUploadFileSize {
|
||||
dry.POST("/open-apis/drive/v1/medias/upload_prepare").
|
||||
Desc("[1a] Initialize multipart upload").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": spec.SourceFileName(),
|
||||
"parent_type": "ccm_import_open",
|
||||
"parent_node": "",
|
||||
"size": "<file_size>",
|
||||
"extra": extra,
|
||||
})
|
||||
dry.POST("/open-apis/drive/v1/medias/upload_part").
|
||||
Desc("[1b] Upload file parts (repeated)").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"seq": "<chunk_index>",
|
||||
"size": "<chunk_size>",
|
||||
"file": "<chunk_binary>",
|
||||
})
|
||||
dry.POST("/open-apis/drive/v1/medias/upload_finish").
|
||||
Desc("[1c] Finalize multipart upload and get file_token").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"block_num": "<block_num>",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
dry.POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Desc("[1] Upload file to get file_token").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": spec.SourceFileName(),
|
||||
"parent_type": "ccm_import_open",
|
||||
"size": "<file_size>",
|
||||
"extra": extra,
|
||||
"file": "@" + spec.FilePath,
|
||||
})
|
||||
}
|
||||
|
||||
// importTargetFileName returns the explicit import name when present, otherwise
|
||||
// derives one from the local file name.
|
||||
func importTargetFileName(filePath, explicitName string) string {
|
||||
if explicitName != "" {
|
||||
return explicitName
|
||||
}
|
||||
return importDefaultFileName(filePath)
|
||||
}
|
||||
|
||||
// importDefaultFileName strips only the last extension so names like
|
||||
// "report.final.csv" become "report.final".
|
||||
func importDefaultFileName(filePath string) string {
|
||||
base := filepath.Base(filePath)
|
||||
ext := filepath.Ext(base)
|
||||
if ext == "" {
|
||||
return base
|
||||
}
|
||||
name := strings.TrimSuffix(base, ext)
|
||||
if name == "" {
|
||||
return base
|
||||
}
|
||||
return name
|
||||
}
|
||||
551
shortcuts/drive/drive_import_common.go
Normal file
551
shortcuts/drive/drive_import_common.go
Normal file
@@ -0,0 +1,551 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var (
|
||||
driveImportPollAttempts = 30
|
||||
driveImportPollInterval = 2 * time.Second
|
||||
)
|
||||
|
||||
const (
|
||||
// These limits follow the current product-side import constraints per format.
|
||||
driveImport20MBFileSizeLimit int64 = 20 * 1024 * 1024
|
||||
driveImport100MBFileSizeLimit int64 = 100 * 1024 * 1024
|
||||
driveImport600MBFileSizeLimit int64 = 600 * 1024 * 1024
|
||||
driveImport800MBFileSizeLimit int64 = 800 * 1024 * 1024
|
||||
)
|
||||
|
||||
type driveMultipartUploadSession struct {
|
||||
UploadID string
|
||||
BlockSize int
|
||||
BlockNum int
|
||||
}
|
||||
|
||||
// driveImportExtToDocTypes defines which source file extensions can be imported
|
||||
// into which Drive-native document types.
|
||||
var driveImportExtToDocTypes = map[string][]string{
|
||||
"docx": {"docx"},
|
||||
"doc": {"docx"},
|
||||
"txt": {"docx"},
|
||||
"md": {"docx"},
|
||||
"mark": {"docx"},
|
||||
"markdown": {"docx"},
|
||||
"html": {"docx"},
|
||||
"xlsx": {"sheet", "bitable"},
|
||||
"xls": {"sheet"},
|
||||
"csv": {"sheet", "bitable"},
|
||||
}
|
||||
|
||||
// driveImportSpec contains the user-facing import inputs after normalization.
|
||||
type driveImportSpec struct {
|
||||
FilePath string
|
||||
DocType string
|
||||
FolderToken string
|
||||
Name string
|
||||
}
|
||||
|
||||
func (s driveImportSpec) FileExtension() string {
|
||||
return strings.TrimPrefix(strings.ToLower(filepath.Ext(s.FilePath)), ".")
|
||||
}
|
||||
|
||||
func (s driveImportSpec) SourceFileName() string {
|
||||
return filepath.Base(s.FilePath)
|
||||
}
|
||||
|
||||
func (s driveImportSpec) TargetFileName() string {
|
||||
return importTargetFileName(s.FilePath, s.Name)
|
||||
}
|
||||
|
||||
// CreateTaskBody builds the request body expected by /drive/v1/import_tasks.
|
||||
func (s driveImportSpec) CreateTaskBody(fileToken string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"file_extension": s.FileExtension(),
|
||||
"file_token": fileToken,
|
||||
"type": s.DocType,
|
||||
"file_name": s.TargetFileName(),
|
||||
"point": map[string]interface{}{
|
||||
"mount_type": 1,
|
||||
// The import API treats an empty mount_key as "use the caller's root
|
||||
// folder", so preserve the zero value when --folder-token is omitted.
|
||||
"mount_key": s.FolderToken,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// uploadMediaForImport uploads the source file to the temporary import media
|
||||
// endpoint and returns the file token consumed by import_tasks.
|
||||
func uploadMediaForImport(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName, docType string) (string, error) {
|
||||
importInfo, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("cannot read file: %s", err)
|
||||
}
|
||||
|
||||
fileSize := importInfo.Size()
|
||||
if err = validateDriveImportFileSize(filePath, docType, fileSize); err != nil {
|
||||
return "", err
|
||||
}
|
||||
fileSizeValue, err := driveUploadSizeValue(fileSize)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
extra, err := buildImportMediaExtra(filePath, docType)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if fileSize <= maxDriveUploadFileSize {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading media for import: %s (%s)\n", fileName, common.FormatSize(fileSize))
|
||||
return uploadMediaForImportAll(runtime, filePath, fileName, fileSizeValue, extra)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading media for import via multipart upload: %s (%s)\n", fileName, common.FormatSize(fileSize))
|
||||
return uploadMediaForImportMultipart(runtime, filePath, fileName, fileSizeValue, extra)
|
||||
}
|
||||
|
||||
func uploadMediaForImportAll(runtime *common.RuntimeContext, filePath, fileName string, fileSize int, extra string) (string, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("cannot read file: %s", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", fileName)
|
||||
fd.AddField("parent_type", "ccm_import_open")
|
||||
fd.AddField("size", fmt.Sprintf("%d", fileSize))
|
||||
fd.AddField("extra", extra)
|
||||
fd.AddFile("file", f)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
return "", wrapDriveUploadRequestError(err, "upload media failed")
|
||||
}
|
||||
|
||||
data, err := parseDriveUploadResponse(apiResp, "upload media failed")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return extractDriveUploadFileToken(data, "upload media failed")
|
||||
}
|
||||
|
||||
func uploadMediaForImportMultipart(runtime *common.RuntimeContext, filePath, fileName string, fileSize int, extra string) (string, error) {
|
||||
session, err := prepareMediaImportUpload(runtime, fileName, fileSize, extra)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload prepare failed: %s\n", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
totalBlocks := session.BlockNum
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", totalBlocks, common.FormatSize(int64(session.BlockSize)))
|
||||
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("cannot read file: %s", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
buffer := make([]byte, session.BlockSize)
|
||||
remaining := fileSize
|
||||
uploadedBlocks := 0
|
||||
for remaining > 0 {
|
||||
chunkSize := session.BlockSize
|
||||
if chunkSize > remaining {
|
||||
chunkSize = remaining
|
||||
}
|
||||
|
||||
n, readErr := io.ReadFull(f, buffer[:chunkSize])
|
||||
if readErr != nil {
|
||||
return "", output.ErrValidation("cannot read file: %s", readErr)
|
||||
}
|
||||
|
||||
if err = uploadMediaImportPart(runtime, session.UploadID, uploadedBlocks, buffer[:n]); err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload part failed: %s\n", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
remaining -= n
|
||||
uploadedBlocks++
|
||||
}
|
||||
|
||||
if session.BlockNum > 0 && session.BlockNum != uploadedBlocks {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "upload prepare mismatch: expected %d blocks, uploaded %d", session.BlockNum, uploadedBlocks)
|
||||
}
|
||||
|
||||
return finishMediaImportUpload(runtime, session.UploadID, uploadedBlocks)
|
||||
}
|
||||
|
||||
func prepareMediaImportUpload(runtime *common.RuntimeContext, fileName string, fileSize int, extra string) (driveMultipartUploadSession, error) {
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/medias/upload_prepare", nil, map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": "ccm_import_open", // For media import uploads, parent_type must be ccm_import_open.
|
||||
"size": fileSize,
|
||||
"extra": extra,
|
||||
"parent_node": "", // For media import uploads, parent_node must be an explicit empty string; unlike medias/upload_all, this field cannot be omitted.
|
||||
})
|
||||
if err != nil {
|
||||
return driveMultipartUploadSession{}, err
|
||||
}
|
||||
|
||||
session := driveMultipartUploadSession{
|
||||
UploadID: common.GetString(data, "upload_id"),
|
||||
BlockSize: int(common.GetFloat(data, "block_size")),
|
||||
BlockNum: int(common.GetFloat(data, "block_num")),
|
||||
}
|
||||
if session.UploadID == "" {
|
||||
return driveMultipartUploadSession{}, output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: no upload_id returned")
|
||||
}
|
||||
if session.BlockSize <= 0 {
|
||||
return driveMultipartUploadSession{}, output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_size returned")
|
||||
}
|
||||
if session.BlockNum <= 0 {
|
||||
return driveMultipartUploadSession{}, output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_num returned")
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func uploadMediaImportPart(runtime *common.RuntimeContext, uploadID string, seq int, chunk []byte) error {
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("upload_id", uploadID)
|
||||
fd.AddField("seq", seq)
|
||||
fd.AddField("size", len(chunk))
|
||||
fd.AddFile("file", bytes.NewReader(chunk))
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/medias/upload_part",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
return wrapDriveUploadRequestError(err, "upload media part failed")
|
||||
}
|
||||
|
||||
_, err = parseDriveUploadResponse(apiResp, "upload media part failed")
|
||||
return err
|
||||
}
|
||||
|
||||
func finishMediaImportUpload(runtime *common.RuntimeContext, uploadID string, blockNum int) (string, error) {
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/medias/upload_finish", nil, map[string]interface{}{
|
||||
"upload_id": uploadID,
|
||||
"block_num": blockNum,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload finish failed: %s\n", err)
|
||||
return "", err
|
||||
}
|
||||
return extractDriveUploadFileToken(data, "upload media finish failed")
|
||||
}
|
||||
|
||||
func buildImportMediaExtra(filePath, docType string) (string, error) {
|
||||
extraBytes, err := json.Marshal(map[string]string{
|
||||
"obj_type": docType,
|
||||
"file_extension": strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), "."),
|
||||
})
|
||||
if err != nil {
|
||||
return "", output.Errorf(output.ExitInternal, "json_error", "build upload extra failed: %v", err)
|
||||
}
|
||||
return string(extraBytes), nil
|
||||
}
|
||||
|
||||
func driveImportFileSizeLimit(filePath, docType string) (int64, bool) {
|
||||
// Keep the limit mapping local to import flows so we do not widen behavior
|
||||
// changes beyond drive +import.
|
||||
switch strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") {
|
||||
case "docx", "doc":
|
||||
return driveImport600MBFileSizeLimit, true
|
||||
case "txt", "md", "mark", "markdown", "html", "xls":
|
||||
return driveImport20MBFileSizeLimit, true
|
||||
case "xlsx":
|
||||
return driveImport800MBFileSizeLimit, true
|
||||
case "csv":
|
||||
if docType == "bitable" {
|
||||
return driveImport100MBFileSizeLimit, true
|
||||
}
|
||||
return driveImport20MBFileSizeLimit, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func validateDriveImportFileSize(filePath, docType string, fileSize int64) error {
|
||||
limit, ok := driveImportFileSizeLimit(filePath, docType)
|
||||
if !ok || fileSize <= limit {
|
||||
return nil
|
||||
}
|
||||
|
||||
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".")
|
||||
if ext == "csv" {
|
||||
// CSV is the only source format whose limit depends on the target type.
|
||||
return output.ErrValidation(
|
||||
"file %s exceeds %s import limit for .csv when importing as %s",
|
||||
common.FormatSize(fileSize),
|
||||
common.FormatSize(limit),
|
||||
docType,
|
||||
)
|
||||
}
|
||||
|
||||
return output.ErrValidation(
|
||||
"file %s exceeds %s import limit for .%s",
|
||||
common.FormatSize(fileSize),
|
||||
common.FormatSize(limit),
|
||||
ext,
|
||||
)
|
||||
}
|
||||
|
||||
func driveUploadSizeValue(fileSize int64) (int, error) {
|
||||
maxInt := int64(^uint(0) >> 1)
|
||||
if fileSize > maxInt {
|
||||
return 0, output.ErrValidation("file %s is too large to upload", common.FormatSize(fileSize))
|
||||
}
|
||||
return int(fileSize), nil
|
||||
}
|
||||
|
||||
func wrapDriveUploadRequestError(err error, action string) error {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
}
|
||||
return output.ErrNetwork("%s: %v", action, err)
|
||||
}
|
||||
|
||||
func parseDriveUploadResponse(apiResp *larkcore.ApiResp, action string) (map[string]interface{}, error) {
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "%s: invalid response JSON: %v", action, err)
|
||||
}
|
||||
|
||||
if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 {
|
||||
msg, _ := result["msg"].(string)
|
||||
return nil, output.ErrAPI(larkCode, fmt.Sprintf("%s: [%d] %s", action, larkCode, msg), result["error"])
|
||||
}
|
||||
|
||||
data, _ := result["data"].(map[string]interface{})
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func extractDriveUploadFileToken(data map[string]interface{}, action string) (string, error) {
|
||||
fileToken := common.GetString(data, "file_token")
|
||||
if fileToken == "" {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "%s: no file_token returned", action)
|
||||
}
|
||||
return fileToken, nil
|
||||
}
|
||||
|
||||
// validateDriveImportSpec enforces the CLI-level compatibility rules before any
|
||||
// upload or import request is sent to the backend.
|
||||
func validateDriveImportSpec(spec driveImportSpec) error {
|
||||
ext := spec.FileExtension()
|
||||
if ext == "" {
|
||||
return output.ErrValidation("file must have an extension (e.g. .md, .docx, .xlsx)")
|
||||
}
|
||||
|
||||
switch spec.DocType {
|
||||
case "docx", "sheet", "bitable":
|
||||
default:
|
||||
return output.ErrValidation("unsupported target document type: %s. Supported types are: docx, sheet, bitable", spec.DocType)
|
||||
}
|
||||
|
||||
supportedTypes, ok := driveImportExtToDocTypes[ext]
|
||||
if !ok {
|
||||
return output.ErrValidation("unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv", ext)
|
||||
}
|
||||
|
||||
typeAllowed := false
|
||||
// Validate the extension/type pair locally so users get a precise error
|
||||
// before the file upload step.
|
||||
for _, allowedType := range supportedTypes {
|
||||
if allowedType == spec.DocType {
|
||||
typeAllowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !typeAllowed {
|
||||
var hint string
|
||||
switch ext {
|
||||
case "xlsx", "csv":
|
||||
hint = fmt.Sprintf(".%s files can only be imported as 'sheet' or 'bitable', not '%s'", ext, spec.DocType)
|
||||
case "xls":
|
||||
hint = fmt.Sprintf(".xls files can only be imported as 'sheet', not '%s'", spec.DocType)
|
||||
default:
|
||||
hint = fmt.Sprintf(".%s files can only be imported as 'docx', not '%s'", ext, spec.DocType)
|
||||
}
|
||||
return output.ErrValidation("file type mismatch: %s", hint)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(spec.FolderToken) != "" {
|
||||
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// driveImportStatus captures the backend fields needed to decide whether the
|
||||
// import can be surfaced immediately or requires a follow-up poll.
|
||||
type driveImportStatus struct {
|
||||
Ticket string
|
||||
DocType string
|
||||
Token string
|
||||
URL string
|
||||
JobErrorMsg string
|
||||
Extra interface{}
|
||||
JobStatus int
|
||||
}
|
||||
|
||||
func (s driveImportStatus) Ready() bool {
|
||||
return s.Token != "" && s.JobStatus == 0
|
||||
}
|
||||
|
||||
func (s driveImportStatus) Pending() bool {
|
||||
return s.JobStatus == 1 || s.JobStatus == 2 || (s.JobStatus == 0 && s.Token == "")
|
||||
}
|
||||
|
||||
func (s driveImportStatus) Failed() bool {
|
||||
return !s.Ready() && !s.Pending() && s.JobStatus != 0
|
||||
}
|
||||
|
||||
func (s driveImportStatus) StatusLabel() string {
|
||||
switch s.JobStatus {
|
||||
case 0:
|
||||
// Some responses report status=0 before the imported token is materialized.
|
||||
// Treat that intermediate state as pending rather than completed.
|
||||
if s.Token == "" {
|
||||
return "pending"
|
||||
}
|
||||
return "success"
|
||||
case 1:
|
||||
return "new"
|
||||
case 2:
|
||||
return "processing"
|
||||
default:
|
||||
return fmt.Sprintf("status_%d", s.JobStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// driveImportTaskResultCommand prints the resume command returned after bounded
|
||||
// polling times out locally.
|
||||
func driveImportTaskResultCommand(ticket string) string {
|
||||
return fmt.Sprintf("lark-cli drive +task_result --scenario import --ticket %s", ticket)
|
||||
}
|
||||
|
||||
// createDriveImportTask creates the server-side import task after the media
|
||||
// upload has produced a reusable file token.
|
||||
func createDriveImportTask(runtime *common.RuntimeContext, spec driveImportSpec, fileToken string) (string, error) {
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/import_tasks", nil, spec.CreateTaskBody(fileToken))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ticket := common.GetString(data, "ticket")
|
||||
if ticket == "" {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "no ticket returned from import_tasks")
|
||||
}
|
||||
return ticket, nil
|
||||
}
|
||||
|
||||
// getDriveImportStatus fetches the current state of an import task by ticket.
|
||||
func getDriveImportStatus(runtime *common.RuntimeContext, ticket string) (driveImportStatus, error) {
|
||||
if err := validate.ResourceName(ticket, "--ticket"); err != nil {
|
||||
return driveImportStatus{}, output.ErrValidation("%s", err)
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/drive/v1/import_tasks/%s", validate.EncodePathSegment(ticket)),
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return driveImportStatus{}, err
|
||||
}
|
||||
|
||||
return parseDriveImportStatus(ticket, data), nil
|
||||
}
|
||||
|
||||
// parseDriveImportStatus accepts either the wrapped API response or an already
|
||||
// extracted result object to keep the helper easy to test.
|
||||
func parseDriveImportStatus(ticket string, data map[string]interface{}) driveImportStatus {
|
||||
result := common.GetMap(data, "result")
|
||||
if result == nil {
|
||||
// Some tests and helper call sites already pass the unwrapped result body.
|
||||
result = data
|
||||
}
|
||||
|
||||
return driveImportStatus{
|
||||
Ticket: ticket,
|
||||
DocType: common.GetString(result, "type"),
|
||||
Token: common.GetString(result, "token"),
|
||||
URL: common.GetString(result, "url"),
|
||||
JobErrorMsg: common.GetString(result, "job_error_msg"),
|
||||
Extra: result["extra"],
|
||||
JobStatus: int(common.GetFloat(result, "job_status")),
|
||||
}
|
||||
}
|
||||
|
||||
// pollDriveImportTask waits for the import to finish within a bounded window
|
||||
// and returns the last observed status for resume-on-timeout flows.
|
||||
func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveImportStatus, bool, error) {
|
||||
lastStatus := driveImportStatus{Ticket: ticket}
|
||||
var lastErr error
|
||||
hadSuccessfulPoll := false
|
||||
for attempt := 1; attempt <= driveImportPollAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
time.Sleep(driveImportPollInterval)
|
||||
}
|
||||
|
||||
status, err := getDriveImportStatus(runtime, ticket)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
// Log the error but continue polling.
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Import status attempt %d/%d failed: %v\n", attempt, driveImportPollAttempts, err)
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
hadSuccessfulPoll = true
|
||||
|
||||
// Stop immediately on terminal states and otherwise return the last known
|
||||
// status so the caller can expose a follow-up command on timeout.
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Import completed successfully.\n")
|
||||
return status, true, nil
|
||||
}
|
||||
if status.Failed() {
|
||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
}
|
||||
return status, false, output.Errorf(output.ExitAPI, "api_error", "import failed with status %d: %s", status.JobStatus, msg)
|
||||
}
|
||||
}
|
||||
if !hadSuccessfulPoll && lastErr != nil {
|
||||
return lastStatus, false, lastErr
|
||||
}
|
||||
|
||||
return lastStatus, false, nil
|
||||
}
|
||||
639
shortcuts/drive/drive_import_common_test.go
Normal file
639
shortcuts/drive/drive_import_common_test.go
Normal file
@@ -0,0 +1,639 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestValidateDriveImportSpecRejectsMismatchedType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateDriveImportSpec(driveImportSpec{
|
||||
FilePath: "./data.xlsx",
|
||||
DocType: "docx",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "file type mismatch") {
|
||||
t.Fatalf("expected file type mismatch error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDriveImportSpecRejectsXlsBitable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateDriveImportSpec(driveImportSpec{
|
||||
FilePath: "./data.xls",
|
||||
DocType: "bitable",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), ".xls files can only be imported as 'sheet'") {
|
||||
t.Fatalf("expected xls-only-sheet validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDriveImportFileSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
filePath string
|
||||
docType string
|
||||
fileSize int64
|
||||
wantText string
|
||||
}{
|
||||
{
|
||||
name: "docx exceeds 600mb limit",
|
||||
filePath: "./report.docx",
|
||||
docType: "docx",
|
||||
fileSize: driveImport600MBFileSizeLimit + 1,
|
||||
wantText: "exceeds 600.0 MB import limit for .docx",
|
||||
},
|
||||
{
|
||||
name: "csv sheet exceeds 20mb limit",
|
||||
filePath: "./data.csv",
|
||||
docType: "sheet",
|
||||
fileSize: driveImport20MBFileSizeLimit + 1,
|
||||
wantText: "exceeds 20.0 MB import limit for .csv when importing as sheet",
|
||||
},
|
||||
{
|
||||
name: "csv bitable exceeds 100mb limit",
|
||||
filePath: "./data.csv",
|
||||
docType: "bitable",
|
||||
fileSize: driveImport100MBFileSizeLimit + 1,
|
||||
wantText: "exceeds 100.0 MB import limit for .csv when importing as bitable",
|
||||
},
|
||||
{
|
||||
name: "xlsx within 800mb limit",
|
||||
filePath: "./data.xlsx",
|
||||
docType: "sheet",
|
||||
fileSize: driveImport800MBFileSizeLimit,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateDriveImportFileSize(tt.filePath, tt.docType, tt.fileSize)
|
||||
if tt.wantText == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantText) {
|
||||
t.Fatalf("error = %v, want substring %q", err, tt.wantText)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDriveImportStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
status := parseDriveImportStatus("tk_123", map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"type": "sheet",
|
||||
"job_status": 0,
|
||||
"job_error_msg": "",
|
||||
"token": "sheet_123",
|
||||
"url": "https://example.com/sheets/sheet_123",
|
||||
"extra": []interface{}{"2000"},
|
||||
},
|
||||
})
|
||||
|
||||
if !status.Ready() {
|
||||
t.Fatal("expected import status to be ready")
|
||||
}
|
||||
if status.StatusLabel() != "success" {
|
||||
t.Fatalf("status label = %q, want %q", status.StatusLabel(), "success")
|
||||
}
|
||||
if status.Token != "sheet_123" {
|
||||
t.Fatalf("token = %q, want %q", status.Token, "sheet_123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportStatusPendingWithoutToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
status := driveImportStatus{JobStatus: 0}
|
||||
if status.Ready() {
|
||||
t.Fatal("expected status without token to be not ready")
|
||||
}
|
||||
if !status.Pending() {
|
||||
t.Fatal("expected status without token to be pending")
|
||||
}
|
||||
if got := status.StatusLabel(); got != "pending" {
|
||||
t.Fatalf("StatusLabel() = %q, want %q", got, "pending")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"file_token": "file_123"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/import_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_import"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/import_tasks/tk_import",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"type": "sheet",
|
||||
"job_status": 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile("data.xlsx", []byte("fake-xlsx"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
prevAttempts, prevInterval := driveImportPollAttempts, driveImportPollInterval
|
||||
driveImportPollAttempts, driveImportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveImportPollAttempts, driveImportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveImport, []string{
|
||||
"+import",
|
||||
"--file", "data.xlsx",
|
||||
"--type", "sheet",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) {
|
||||
t.Fatalf("stdout missing ready=false: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"timed_out": true`)) {
|
||||
t.Fatalf("stdout missing timed_out=true: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"next_command": "lark-cli drive +task_result --scenario import --ticket tk_import"`)) {
|
||||
t.Fatalf("stdout missing follow-up command: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportUsesMultipartUploadForLargeFile(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
|
||||
prepareStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_size": 4 * 1024 * 1024,
|
||||
"block_num": 6,
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(prepareStub)
|
||||
|
||||
partStubs := make([]*httpmock.Stub, 0, 6)
|
||||
for i := 0; i < 6; i++ {
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_part",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
}
|
||||
partStubs = append(partStubs, stub)
|
||||
reg.Register(stub)
|
||||
}
|
||||
|
||||
finishStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_finish",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "file_123",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(finishStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/import_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_import"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/import_tasks/tk_import",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"type": "sheet",
|
||||
"job_status": 0,
|
||||
"token": "sheet_123",
|
||||
"url": "https://example.com/sheets/sheet_123",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
writeSizedDriveImportFile(t, "large.xlsx", int64(maxDriveUploadFileSize)+1)
|
||||
|
||||
err := mountAndRunDrive(t, DriveImport, []string{
|
||||
"+import",
|
||||
"--file", "large.xlsx",
|
||||
"--type", "sheet",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"token": "sheet_123"`)) {
|
||||
t.Fatalf("stdout missing imported token: %s", stdout.String())
|
||||
}
|
||||
|
||||
prepareBody := decodeCapturedJSONBody(t, prepareStub)
|
||||
if got, _ := prepareBody["parent_type"].(string); got != "ccm_import_open" {
|
||||
t.Fatalf("prepare parent_type = %q, want %q", got, "ccm_import_open")
|
||||
}
|
||||
if got, _ := prepareBody["file_name"].(string); got != "large.xlsx" {
|
||||
t.Fatalf("prepare file_name = %q, want %q", got, "large.xlsx")
|
||||
}
|
||||
if got, _ := prepareBody["size"].(float64); got != float64(maxDriveUploadFileSize+1) {
|
||||
t.Fatalf("prepare size = %v, want %d", got, maxDriveUploadFileSize+1)
|
||||
}
|
||||
|
||||
firstPart := decodeCapturedMultipartBody(t, partStubs[0])
|
||||
if got := firstPart.Fields["upload_id"]; got != "upload_123" {
|
||||
t.Fatalf("first part upload_id = %q, want %q", got, "upload_123")
|
||||
}
|
||||
if got := firstPart.Fields["seq"]; got != "0" {
|
||||
t.Fatalf("first part seq = %q, want %q", got, "0")
|
||||
}
|
||||
if got := firstPart.Fields["size"]; got != "4194304" {
|
||||
t.Fatalf("first part size = %q, want %q", got, "4194304")
|
||||
}
|
||||
if got := len(firstPart.Files["file"]); got != 4*1024*1024 {
|
||||
t.Fatalf("first part file size = %d, want %d", got, 4*1024*1024)
|
||||
}
|
||||
|
||||
lastPart := decodeCapturedMultipartBody(t, partStubs[len(partStubs)-1])
|
||||
if got := lastPart.Fields["seq"]; got != "5" {
|
||||
t.Fatalf("last part seq = %q, want %q", got, "5")
|
||||
}
|
||||
if got := lastPart.Fields["size"]; got != "1" {
|
||||
t.Fatalf("last part size = %q, want %q", got, "1")
|
||||
}
|
||||
if got := len(lastPart.Files["file"]); got != 1 {
|
||||
t.Fatalf("last part file size = %d, want %d", got, 1)
|
||||
}
|
||||
|
||||
finishBody := decodeCapturedJSONBody(t, finishStub)
|
||||
if got, _ := finishBody["upload_id"].(string); got != "upload_123" {
|
||||
t.Fatalf("finish upload_id = %q, want %q", got, "upload_123")
|
||||
}
|
||||
if got, _ := finishBody["block_num"].(float64); got != 6 {
|
||||
t.Fatalf("finish block_num = %v, want %d", got, 6)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportMultipartPrepareValidatesResponseFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data map[string]interface{}
|
||||
wantText string
|
||||
}{
|
||||
{
|
||||
name: "missing upload id",
|
||||
data: map[string]interface{}{
|
||||
"block_size": 4 * 1024 * 1024,
|
||||
"block_num": 6,
|
||||
},
|
||||
wantText: "upload prepare failed: no upload_id returned",
|
||||
},
|
||||
{
|
||||
name: "missing block size",
|
||||
data: map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_num": 6,
|
||||
},
|
||||
wantText: "upload prepare failed: invalid block_size returned",
|
||||
},
|
||||
{
|
||||
name: "missing block num",
|
||||
data: map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_size": 4 * 1024 * 1024,
|
||||
},
|
||||
wantText: "upload prepare failed: invalid block_num returned",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": tt.data,
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
writeSizedDriveImportFile(t, "large.xlsx", int64(maxDriveUploadFileSize)+1)
|
||||
|
||||
err := mountAndRunDrive(t, DriveImport, []string{
|
||||
"+import",
|
||||
"--file", "large.xlsx",
|
||||
"--type", "sheet",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantText) {
|
||||
t.Fatalf("error = %v, want substring %q", err, tt.wantText)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportMultipartUploadPartAPIFailure(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_size": 4 * 1024 * 1024,
|
||||
"block_num": 6,
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_part",
|
||||
Body: map[string]interface{}{
|
||||
"code": 999,
|
||||
"msg": "chunk rejected",
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
writeSizedDriveImportFile(t, "large.xlsx", int64(maxDriveUploadFileSize)+1)
|
||||
|
||||
err := mountAndRunDrive(t, DriveImport, []string{
|
||||
"+import",
|
||||
"--file", "large.xlsx",
|
||||
"--type", "sheet",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "upload media part failed: [999] chunk rejected") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportMultipartFinishRequiresFileToken(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_size": 4 * 1024 * 1024,
|
||||
"block_num": 6,
|
||||
},
|
||||
},
|
||||
})
|
||||
for i := 0; i < 6; i++ {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_part",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
})
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_finish",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
writeSizedDriveImportFile(t, "large.xlsx", int64(maxDriveUploadFileSize)+1)
|
||||
|
||||
err := mountAndRunDrive(t, DriveImport, []string{
|
||||
"+import",
|
||||
"--file", "large.xlsx",
|
||||
"--type", "sheet",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "upload media finish failed: no file_token returned") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportRejectsOversizedFileByImportLimit(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
writeSizedDriveImportFile(t, "too-large.csv", driveImport100MBFileSizeLimit+1)
|
||||
|
||||
err := mountAndRunDrive(t, DriveImport, []string{
|
||||
"+import",
|
||||
"--file", "too-large.csv",
|
||||
"--type", "bitable",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected size limit error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "exceeds 100.0 MB import limit for .csv when importing as bitable") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDriveUploadResponseErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("invalid json", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseDriveUploadResponse(&larkcore.ApiResp{RawBody: []byte("{")}, "upload media failed")
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid response JSON") {
|
||||
t.Fatalf("expected invalid JSON error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("api code error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseDriveUploadResponse(&larkcore.ApiResp{RawBody: []byte(`{"code":999,"msg":"boom","error":{"detail":"x"}}`)}, "upload media failed")
|
||||
if err == nil || !strings.Contains(err.Error(), "upload media failed: [999] boom") {
|
||||
t.Fatalf("expected API error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWrapDriveUploadRequestError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("preserves exit error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
original := output.ErrValidation("bad input")
|
||||
got := wrapDriveUploadRequestError(original, "upload media failed")
|
||||
if got != original {
|
||||
t.Fatalf("expected same exit error pointer, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wraps generic error as network", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := wrapDriveUploadRequestError(io.EOF, "upload media failed")
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T", got)
|
||||
}
|
||||
if exitErr.Code != output.ExitNetwork {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitNetwork)
|
||||
}
|
||||
if !strings.Contains(got.Error(), "upload media failed") {
|
||||
t.Fatalf("unexpected error: %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type capturedMultipartBody struct {
|
||||
Fields map[string]string
|
||||
Files map[string][]byte
|
||||
}
|
||||
|
||||
func decodeCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode captured JSON body: %v", err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func writeSizedDriveImportFile(t *testing.T, name string, size int64) {
|
||||
t.Helper()
|
||||
|
||||
fh, err := os.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Create(%q) error: %v", name, err)
|
||||
}
|
||||
if err := fh.Truncate(size); err != nil {
|
||||
t.Fatalf("Truncate(%q) error: %v", name, err)
|
||||
}
|
||||
if err := fh.Close(); err != nil {
|
||||
t.Fatalf("Close(%q) error: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeCapturedMultipartBody(t *testing.T, stub *httpmock.Stub) capturedMultipartBody {
|
||||
t.Helper()
|
||||
|
||||
contentType := stub.CapturedHeaders.Get("Content-Type")
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
t.Fatalf("parse multipart content type: %v", err)
|
||||
}
|
||||
if mediaType != "multipart/form-data" {
|
||||
t.Fatalf("content type = %q, want multipart/form-data", mediaType)
|
||||
}
|
||||
|
||||
reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
|
||||
body := capturedMultipartBody{
|
||||
Fields: map[string]string{},
|
||||
Files: map[string][]byte{},
|
||||
}
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read multipart part: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(part)
|
||||
if err != nil {
|
||||
t.Fatalf("read multipart data: %v", err)
|
||||
}
|
||||
if part.FileName() != "" {
|
||||
body.Files[part.FormName()] = data
|
||||
continue
|
||||
}
|
||||
body.Fields[part.FormName()] = string(data)
|
||||
}
|
||||
return body
|
||||
}
|
||||
363
shortcuts/drive/drive_import_test.go
Normal file
363
shortcuts/drive/drive_import_test.go
Normal file
@@ -0,0 +1,363 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestImportDefaultFileName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
filePath string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "strip xlsx extension",
|
||||
filePath: "/tmp/base-import.xlsx",
|
||||
want: "base-import",
|
||||
},
|
||||
{
|
||||
name: "strip last extension only",
|
||||
filePath: "/tmp/report.final.csv",
|
||||
want: "report.final",
|
||||
},
|
||||
{
|
||||
name: "keep name without extension",
|
||||
filePath: "/tmp/README",
|
||||
want: "README",
|
||||
},
|
||||
{
|
||||
name: "keep hidden file name when trim would be empty",
|
||||
filePath: "/tmp/.env",
|
||||
want: ".env",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := importDefaultFileName(tt.filePath); got != tt.want {
|
||||
t.Fatalf("importDefaultFileName(%q) = %q, want %q", tt.filePath, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportTargetFileName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := importTargetFileName("/tmp/base-import.xlsx", "custom-name.xlsx"); got != "custom-name.xlsx" {
|
||||
t.Fatalf("explicit name should win, got %q", got)
|
||||
}
|
||||
if got := importTargetFileName("/tmp/base-import.xlsx", ""); got != "base-import" {
|
||||
t.Fatalf("default import name = %q, want %q", got, "base-import")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
if err := os.WriteFile("base-import.xlsx", []byte("fake-xlsx"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +import"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
if err := cmd.Flags().Set("file", "./base-import.xlsx"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", "bitable"); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("folder-token", "fld_test"); err != nil {
|
||||
t.Fatalf("set --folder-token: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
|
||||
dry := DriveImport.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 3 {
|
||||
t.Fatalf("expected 3 API calls, got %d", len(got.API))
|
||||
}
|
||||
|
||||
uploadName, _ := got.API[0].Body["file_name"].(string)
|
||||
if uploadName != "base-import.xlsx" {
|
||||
t.Fatalf("upload file_name = %q, want %q", uploadName, "base-import.xlsx")
|
||||
}
|
||||
|
||||
importName, _ := got.API[1].Body["file_name"].(string)
|
||||
if importName != "base-import" {
|
||||
t.Fatalf("import task file_name = %q, want %q", importName, "base-import")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportDryRunShowsMultipartUploadForLargeFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
fh, err := os.Create("large.xlsx")
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error: %v", err)
|
||||
}
|
||||
if err := fh.Truncate(int64(maxDriveUploadFileSize) + 1); err != nil {
|
||||
t.Fatalf("Truncate() error: %v", err)
|
||||
}
|
||||
if err := fh.Close(); err != nil {
|
||||
t.Fatalf("Close() error: %v", err)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +import"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
if err := cmd.Flags().Set("file", "./large.xlsx"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", "sheet"); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
|
||||
dry := DriveImport.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 5 {
|
||||
t.Fatalf("expected 5 API calls, got %d", len(got.API))
|
||||
}
|
||||
if got.API[0].URL != "/open-apis/drive/v1/medias/upload_prepare" {
|
||||
t.Fatalf("dry-run first URL = %q, want upload_prepare", got.API[0].URL)
|
||||
}
|
||||
if got.API[1].URL != "/open-apis/drive/v1/medias/upload_part" {
|
||||
t.Fatalf("dry-run second URL = %q, want upload_part", got.API[1].URL)
|
||||
}
|
||||
if got.API[2].URL != "/open-apis/drive/v1/medias/upload_finish" {
|
||||
t.Fatalf("dry-run third URL = %q, want upload_finish", got.API[2].URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportDryRunReturnsErrorForUnsafePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +import"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
if err := cmd.Flags().Set("file", "../outside.md"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", "docx"); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
dry := DriveImport.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct{} `json:"api"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if got.Error == "" || !strings.Contains(got.Error, "unsafe file path") {
|
||||
t.Fatalf("dry-run error = %q, want unsafe file path error", got.Error)
|
||||
}
|
||||
if len(got.API) != 0 {
|
||||
t.Fatalf("expected no API calls when preflight fails, got %d", len(got.API))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportDryRunReturnsErrorForOversizedMarkdown(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
fh, err := os.Create("large.md")
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error: %v", err)
|
||||
}
|
||||
if err := fh.Truncate(driveImport20MBFileSizeLimit + 5*1024*1024); err != nil {
|
||||
t.Fatalf("Truncate() error: %v", err)
|
||||
}
|
||||
if err := fh.Close(); err != nil {
|
||||
t.Fatalf("Close() error: %v", err)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +import"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
if err := cmd.Flags().Set("file", "./large.md"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", "docx"); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
|
||||
dry := DriveImport.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct{} `json:"api"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if got.Error == "" || !strings.Contains(got.Error, "exceeds 20.0 MB import limit for .md") {
|
||||
t.Fatalf("dry-run error = %q, want oversized markdown error", got.Error)
|
||||
}
|
||||
if len(got.API) != 0 {
|
||||
t.Fatalf("expected no API calls when size preflight fails, got %d", len(got.API))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportDryRunReturnsErrorForDirectoryInput(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
if err := os.Mkdir("folder-input", 0755); err != nil {
|
||||
t.Fatalf("Mkdir() error: %v", err)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +import"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
if err := cmd.Flags().Set("file", "./folder-input"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", "docx"); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
|
||||
dry := DriveImport.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct{} `json:"api"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if got.Error == "" || !strings.Contains(got.Error, "file must be a regular file") {
|
||||
t.Fatalf("dry-run error = %q, want regular file error", got.Error)
|
||||
}
|
||||
if len(got.API) != 0 {
|
||||
t.Fatalf("expected no API calls when file type preflight fails, got %d", len(got.API))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportCreateTaskBodyKeepsEmptyMountKeyForRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec := driveImportSpec{
|
||||
FilePath: "/tmp/README.md",
|
||||
DocType: "docx",
|
||||
}
|
||||
|
||||
body := spec.CreateTaskBody("file_token_test")
|
||||
point, ok := body["point"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("point = %#v, want map", body["point"])
|
||||
}
|
||||
|
||||
raw, exists := point["mount_key"]
|
||||
if !exists {
|
||||
t.Fatal("mount_key missing; want empty string for root import")
|
||||
}
|
||||
got, ok := raw.(string)
|
||||
if !ok {
|
||||
t.Fatalf("mount_key type = %T, want string", raw)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("mount_key = %q, want empty string for root import", got)
|
||||
}
|
||||
|
||||
spec.FolderToken = "fld_test"
|
||||
body = spec.CreateTaskBody("file_token_test")
|
||||
point, ok = body["point"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("point = %#v, want map", body["point"])
|
||||
}
|
||||
if got, _ := point["mount_key"].(string); got != "fld_test" {
|
||||
t.Fatalf("mount_key = %q, want %q", got, "fld_test")
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,11 @@ package drive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -18,9 +20,11 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var driveTestConfigSeq atomic.Int64
|
||||
|
||||
func driveTestConfig() *core.CliConfig {
|
||||
return &core.CliConfig{
|
||||
AppID: "drive-test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
AppID: fmt.Sprintf("drive-test-app-%d", driveTestConfigSeq.Add(1)), AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
153
shortcuts/drive/drive_move.go
Normal file
153
shortcuts/drive/drive_move.go
Normal file
@@ -0,0 +1,153 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// DriveMove moves a Drive file or folder and handles the async task polling
|
||||
// required by folder moves.
|
||||
var DriveMove = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+move",
|
||||
Description: "Move a file or folder to another location in Drive",
|
||||
Risk: "write",
|
||||
Scopes: []string{"space:document:move"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "file or folder token to move", Required: true},
|
||||
{Name: "type", Desc: "file type (file, docx, bitable, doc, sheet, mindnote, folder, slides)", Required: true},
|
||||
{Name: "folder-token", Desc: "target folder token (default: root folder)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveMoveSpec(driveMoveSpec{
|
||||
FileToken: runtime.Str("file-token"),
|
||||
FileType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
})
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := driveMoveSpec{
|
||||
FileToken: runtime.Str("file-token"),
|
||||
FileType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI().
|
||||
Desc("Move file or folder in Drive")
|
||||
|
||||
dry.POST("/open-apis/drive/v1/files/:file_token/move").
|
||||
Desc("[1] Move file/folder").
|
||||
Set("file_token", spec.FileToken).
|
||||
Body(spec.RequestBody())
|
||||
|
||||
// If moving a folder, show the async task check step
|
||||
if spec.FileType == "folder" {
|
||||
dry.GET("/open-apis/drive/v1/files/task_check").
|
||||
Desc("[2] Poll async task status (for folder move)").
|
||||
Params(driveTaskCheckParams("<task_id>"))
|
||||
}
|
||||
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := driveMoveSpec{
|
||||
FileToken: runtime.Str("file-token"),
|
||||
FileType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
}
|
||||
|
||||
// Default to the caller's root folder so the command can move items
|
||||
// without requiring an explicit destination in common cases.
|
||||
if spec.FolderToken == "" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "No target folder specified, getting root folder...\n")
|
||||
rootToken, err := getRootFolderToken(ctx, runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rootToken == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "get root folder token failed, root folder is empty")
|
||||
}
|
||||
spec.FolderToken = rootToken
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Moving %s %s to folder %s...\n", spec.FileType, common.MaskToken(spec.FileToken), common.MaskToken(spec.FolderToken))
|
||||
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
fmt.Sprintf("/open-apis/drive/v1/files/%s/move", validate.EncodePathSegment(spec.FileToken)),
|
||||
nil,
|
||||
spec.RequestBody(),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Folder moves are asynchronous; file moves complete in the initial call.
|
||||
if spec.FileType == "folder" {
|
||||
taskID := common.GetString(data, "task_id")
|
||||
if taskID == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "move folder returned no task_id")
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Folder move is async, polling task %s...\n", taskID)
|
||||
|
||||
status, ready, err := pollDriveTaskCheck(runtime, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Include both the source and destination identifiers so a timed-out
|
||||
// folder move can be resumed or inspected without reconstructing inputs.
|
||||
out := map[string]interface{}{
|
||||
"task_id": taskID,
|
||||
"status": status.StatusLabel(),
|
||||
"file_token": spec.FileToken,
|
||||
"folder_token": spec.FolderToken,
|
||||
"ready": ready,
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := driveTaskCheckResultCommand(taskID)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Folder move task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
} else {
|
||||
// Non-folder moves are synchronous, so the initial request is the final
|
||||
// outcome and no follow-up task metadata is needed.
|
||||
runtime.Out(map[string]interface{}{
|
||||
"file_token": spec.FileToken,
|
||||
"folder_token": spec.FolderToken,
|
||||
"type": spec.FileType,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// getRootFolderToken resolves the caller's Drive root folder token so other
|
||||
// commands can safely use it as a default destination.
|
||||
func getRootFolderToken(ctx context.Context, runtime *common.RuntimeContext) (string, error) {
|
||||
data, err := runtime.CallAPI("GET", "/open-apis/drive/explorer/v2/root_folder/meta", nil, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
token := common.GetString(data, "token")
|
||||
if token == "" {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "root_folder/meta returned no token")
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
160
shortcuts/drive/drive_move_common.go
Normal file
160
shortcuts/drive/drive_move_common.go
Normal file
@@ -0,0 +1,160 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var (
|
||||
driveMovePollAttempts = 30
|
||||
driveMovePollInterval = 2 * time.Second
|
||||
)
|
||||
|
||||
// driveMoveAllowedTypes mirrors the document kinds accepted by the Drive move
|
||||
// endpoint that this shortcut wraps.
|
||||
var driveMoveAllowedTypes = map[string]bool{
|
||||
"file": true,
|
||||
"docx": true,
|
||||
"bitable": true,
|
||||
"doc": true,
|
||||
"sheet": true,
|
||||
"mindnote": true,
|
||||
"folder": true,
|
||||
"slides": true,
|
||||
}
|
||||
|
||||
// driveMoveSpec contains the normalized input needed to issue a move request.
|
||||
type driveMoveSpec struct {
|
||||
FileToken string
|
||||
FileType string
|
||||
FolderToken string
|
||||
}
|
||||
|
||||
func (s driveMoveSpec) RequestBody() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": s.FileType,
|
||||
"folder_token": s.FolderToken,
|
||||
}
|
||||
}
|
||||
|
||||
func validateDriveMoveSpec(spec driveMoveSpec) error {
|
||||
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if strings.TrimSpace(spec.FolderToken) != "" {
|
||||
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
if !driveMoveAllowedTypes[spec.FileType] {
|
||||
return output.ErrValidation("unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, folder, slides", spec.FileType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// driveTaskCheckStatus represents the status payload returned by
|
||||
// /drive/v1/files/task_check for async folder operations.
|
||||
type driveTaskCheckStatus struct {
|
||||
TaskID string
|
||||
Status string
|
||||
}
|
||||
|
||||
func (s driveTaskCheckStatus) Ready() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(s.Status), "success")
|
||||
}
|
||||
|
||||
func (s driveTaskCheckStatus) Failed() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(s.Status), "failed")
|
||||
}
|
||||
|
||||
func (s driveTaskCheckStatus) Pending() bool {
|
||||
return !s.Ready() && !s.Failed()
|
||||
}
|
||||
|
||||
func (s driveTaskCheckStatus) StatusLabel() string {
|
||||
status := strings.TrimSpace(s.Status)
|
||||
if status == "" {
|
||||
// Empty status is treated as unknown so callers can still render a
|
||||
// meaningful label instead of an empty string.
|
||||
return "unknown"
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
// driveTaskCheckResultCommand prints the resume command shown when bounded
|
||||
// polling ends before the backend task completes.
|
||||
func driveTaskCheckResultCommand(taskID string) string {
|
||||
return fmt.Sprintf("lark-cli drive +task_result --scenario task_check --task-id %s", taskID)
|
||||
}
|
||||
|
||||
// driveTaskCheckParams keeps the task_check query parameter shape in one place
|
||||
// for both dry-run and execution paths.
|
||||
func driveTaskCheckParams(taskID string) map[string]interface{} {
|
||||
return map[string]interface{}{"task_id": taskID}
|
||||
}
|
||||
|
||||
// getDriveTaskCheckStatus fetches and validates the current state of an async
|
||||
// folder move or delete task.
|
||||
func getDriveTaskCheckStatus(runtime *common.RuntimeContext, taskID string) (driveTaskCheckStatus, error) {
|
||||
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
|
||||
return driveTaskCheckStatus{}, output.ErrValidation("%s", err)
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("GET", "/open-apis/drive/v1/files/task_check", driveTaskCheckParams(taskID), nil)
|
||||
if err != nil {
|
||||
return driveTaskCheckStatus{}, err
|
||||
}
|
||||
|
||||
return parseDriveTaskCheckStatus(taskID, data), nil
|
||||
}
|
||||
|
||||
// parseDriveTaskCheckStatus tolerates both wrapped and already-unwrapped
|
||||
// response shapes used in tests and helpers.
|
||||
func parseDriveTaskCheckStatus(taskID string, data map[string]interface{}) driveTaskCheckStatus {
|
||||
result := common.GetMap(data, "result")
|
||||
if result == nil {
|
||||
result = data
|
||||
}
|
||||
|
||||
return driveTaskCheckStatus{
|
||||
TaskID: taskID,
|
||||
Status: common.GetString(result, "status"),
|
||||
}
|
||||
}
|
||||
|
||||
// pollDriveTaskCheck polls the backend for a bounded period and returns the
|
||||
// last seen status so callers can emit a follow-up command when needed.
|
||||
func pollDriveTaskCheck(runtime *common.RuntimeContext, taskID string) (driveTaskCheckStatus, bool, error) {
|
||||
lastStatus := driveTaskCheckStatus{TaskID: taskID}
|
||||
for attempt := 1; attempt <= driveMovePollAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
time.Sleep(driveMovePollInterval)
|
||||
}
|
||||
|
||||
status, err := getDriveTaskCheckStatus(runtime, taskID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Error polling task %s: %s\n", taskID, err)
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
// Success and failure are terminal backend states. Any other value is kept
|
||||
// as pending so the caller can decide whether to continue or resume later.
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Folder move completed successfully.\n")
|
||||
return status, true, nil
|
||||
}
|
||||
if status.Failed() {
|
||||
return status, false, output.Errorf(output.ExitAPI, "api_error", "folder move task failed")
|
||||
}
|
||||
}
|
||||
|
||||
return lastStatus, false, nil
|
||||
}
|
||||
194
shortcuts/drive/drive_move_common_test.go
Normal file
194
shortcuts/drive/drive_move_common_test.go
Normal file
@@ -0,0 +1,194 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestParseDriveTaskCheckStatusFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
status := parseDriveTaskCheckStatus("task_123", map[string]interface{}{
|
||||
"status": "success",
|
||||
})
|
||||
|
||||
if !status.Ready() {
|
||||
t.Fatal("expected task check status to be ready")
|
||||
}
|
||||
if status.StatusLabel() != "success" {
|
||||
t.Fatalf("status label = %q, want %q", status.StatusLabel(), "success")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveTaskCheckStatusPendingAndUnknownLabel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
status := driveTaskCheckStatus{}
|
||||
if !status.Pending() {
|
||||
t.Fatal("expected empty status to be treated as pending")
|
||||
}
|
||||
if got := status.StatusLabel(); got != "unknown" {
|
||||
t.Fatalf("StatusLabel() = %q, want %q", got, "unknown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDriveMoveSpecRejectsUnsupportedType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateDriveMoveSpec(driveMoveSpec{
|
||||
FileToken: "file_token_test",
|
||||
FileType: "unsupported_type",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported type error, got nil")
|
||||
}
|
||||
if got := err.Error(); !bytes.Contains([]byte(got), []byte("unsupported file type")) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMoveDryRunFolderIncludesTaskCheckParams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +move"}
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
if err := cmd.Flags().Set("file-token", "fld_src"); err != nil {
|
||||
t.Fatalf("set --file-token: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", "folder"); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("folder-token", "fld_dst"); err != nil {
|
||||
t.Fatalf("set --folder-token: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
dry := DriveMove.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 2 {
|
||||
t.Fatalf("expected 2 API calls, got %d", len(got.API))
|
||||
}
|
||||
if got.API[1].Params["task_id"] != "<task_id>" {
|
||||
t.Fatalf("task check params = %#v", got.API[1].Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMoveFolderSuccessUsesTaskCheckHelper(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/fld_src/move",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"task_id": "task_123"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/task_check",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "success"},
|
||||
},
|
||||
})
|
||||
|
||||
prevAttempts, prevInterval := driveMovePollAttempts, driveMovePollInterval
|
||||
driveMovePollAttempts, driveMovePollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveMovePollAttempts, driveMovePollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveMove, []string{
|
||||
"+move",
|
||||
"--file-token", "fld_src",
|
||||
"--type", "folder",
|
||||
"--folder-token", "fld_dst",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"task_id": "task_123"`)) {
|
||||
t.Fatalf("stdout missing task id: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": true`)) {
|
||||
t.Fatalf("stdout missing ready=true: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMoveFolderTimeoutReturnsFollowUpCommand(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/fld_src/move",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"task_id": "task_123"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/task_check",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "pending"},
|
||||
},
|
||||
})
|
||||
|
||||
prevAttempts, prevInterval := driveMovePollAttempts, driveMovePollInterval
|
||||
driveMovePollAttempts, driveMovePollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveMovePollAttempts, driveMovePollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveMove, []string{
|
||||
"+move",
|
||||
"--file-token", "fld_src",
|
||||
"--type", "folder",
|
||||
"--folder-token", "fld_dst",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) {
|
||||
t.Fatalf("stdout missing ready=false: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"timed_out": true`)) {
|
||||
t.Fatalf("stdout missing timed_out=true: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"next_command": "lark-cli drive +task_result --scenario task_check --task-id task_123"`)) {
|
||||
t.Fatalf("stdout missing follow-up command: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
77
shortcuts/drive/drive_move_test.go
Normal file
77
shortcuts/drive/drive_move_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestDriveMoveUsesRootFolderWhenFolderTokenMissing(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/explorer/v2/root_folder/meta",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"token": "folder_root_token_test",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/file_token_test/move",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveMove, []string{
|
||||
"+move",
|
||||
"--file-token", "file_token_test",
|
||||
"--type", "file",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"folder_token": "folder_root_token_test"`) {
|
||||
t.Fatalf("stdout missing resolved root folder token: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"file_token": "file_token_test"`) {
|
||||
t.Fatalf("stdout missing file token: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMoveRootFolderLookupRequiresToken(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/explorer/v2/root_folder/meta",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveMove, []string{
|
||||
"+move",
|
||||
"--file-token", "file_token_test",
|
||||
"--type", "file",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected missing root folder token error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "root_folder/meta returned no token") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
190
shortcuts/drive/drive_task_result.go
Normal file
190
shortcuts/drive/drive_task_result.go
Normal file
@@ -0,0 +1,190 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// DriveTaskResult exposes a unified read path for the async task types produced
|
||||
// by Drive import, export, and folder move flows.
|
||||
var DriveTaskResult = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+task_result",
|
||||
Description: "Poll async task result for import, export, move, or delete operations",
|
||||
Risk: "read",
|
||||
Scopes: []string{"drive:drive.metadata:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "ticket", Desc: "async task ticket (for import/export tasks)", Required: false},
|
||||
{Name: "task-id", Desc: "async task ID (for move/delete folder tasks)", Required: false},
|
||||
{Name: "scenario", Desc: "task scenario: import, export, or task_check", Required: true},
|
||||
{Name: "file-token", Desc: "source document token used for export task status lookup", Required: false},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
scenario := strings.ToLower(runtime.Str("scenario"))
|
||||
validScenarios := map[string]bool{
|
||||
"import": true,
|
||||
"export": true,
|
||||
"task_check": true,
|
||||
}
|
||||
if !validScenarios[scenario] {
|
||||
return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check", scenario)
|
||||
}
|
||||
|
||||
// Validate required params based on scenario
|
||||
switch scenario {
|
||||
case "import", "export":
|
||||
if runtime.Str("ticket") == "" {
|
||||
return output.ErrValidation("--ticket is required for %s scenario", scenario)
|
||||
}
|
||||
if err := validate.ResourceName(runtime.Str("ticket"), "--ticket"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
case "task_check":
|
||||
if runtime.Str("task-id") == "" {
|
||||
return output.ErrValidation("--task-id is required for task_check scenario")
|
||||
}
|
||||
if err := validate.ResourceName(runtime.Str("task-id"), "--task-id"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// For export scenario, file-token is required
|
||||
if scenario == "export" && runtime.Str("file-token") == "" {
|
||||
return output.ErrValidation("--file-token is required for export scenario")
|
||||
}
|
||||
if scenario == "export" {
|
||||
if err := validate.ResourceName(runtime.Str("file-token"), "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
scenario := strings.ToLower(runtime.Str("scenario"))
|
||||
ticket := runtime.Str("ticket")
|
||||
taskID := runtime.Str("task-id")
|
||||
fileToken := runtime.Str("file-token")
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc(fmt.Sprintf("Poll async task result for %s scenario", scenario))
|
||||
|
||||
switch scenario {
|
||||
case "import":
|
||||
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
|
||||
Desc("[1] Query import task result").
|
||||
Set("ticket", ticket)
|
||||
case "export":
|
||||
dry.GET("/open-apis/drive/v1/export_tasks/:ticket").
|
||||
Desc("[1] Query export task result").
|
||||
Set("ticket", ticket).
|
||||
Params(map[string]interface{}{"token": fileToken})
|
||||
case "task_check":
|
||||
dry.GET("/open-apis/drive/v1/files/task_check").
|
||||
Desc("[1] Query move/delete folder task status").
|
||||
Params(driveTaskCheckParams(taskID))
|
||||
}
|
||||
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
scenario := strings.ToLower(runtime.Str("scenario"))
|
||||
ticket := runtime.Str("ticket")
|
||||
taskID := runtime.Str("task-id")
|
||||
fileToken := runtime.Str("file-token")
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Querying %s task result...\n", scenario)
|
||||
|
||||
var result map[string]interface{}
|
||||
var err error
|
||||
|
||||
// Each scenario maps to a different backend API, but this shortcut keeps
|
||||
// the CLI surface uniform for resume-on-timeout workflows.
|
||||
switch scenario {
|
||||
case "import":
|
||||
result, err = queryImportTask(runtime, ticket)
|
||||
case "export":
|
||||
result, err = queryExportTask(runtime, ticket, fileToken)
|
||||
case "task_check":
|
||||
result, err = queryTaskCheck(runtime, taskID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// queryImportTask returns a stable, shortcut-friendly view of the import task.
|
||||
func queryImportTask(runtime *common.RuntimeContext, ticket string) (map[string]interface{}, error) {
|
||||
status, err := getDriveImportStatus(runtime, ticket)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"scenario": "import",
|
||||
"ticket": status.Ticket,
|
||||
"type": status.DocType,
|
||||
"ready": status.Ready(),
|
||||
"failed": status.Failed(),
|
||||
"job_status": status.JobStatus,
|
||||
"job_status_label": status.StatusLabel(),
|
||||
"job_error_msg": status.JobErrorMsg,
|
||||
"token": status.Token,
|
||||
"url": status.URL,
|
||||
"extra": status.Extra,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// queryExportTask returns the export task status together with download metadata
|
||||
// once the backend has produced the exported file.
|
||||
func queryExportTask(runtime *common.RuntimeContext, ticket, fileToken string) (map[string]interface{}, error) {
|
||||
status, err := getDriveExportStatus(runtime, fileToken, ticket)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"scenario": "export",
|
||||
"ticket": status.Ticket,
|
||||
"ready": status.Ready(),
|
||||
"failed": status.Failed(),
|
||||
"file_extension": status.FileExtension,
|
||||
"type": status.DocType,
|
||||
"file_name": status.FileName,
|
||||
"file_token": status.FileToken,
|
||||
"file_size": status.FileSize,
|
||||
"job_error_msg": status.JobErrorMsg,
|
||||
"job_status": status.JobStatus,
|
||||
"job_status_label": status.StatusLabel(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// queryTaskCheck returns the normalized status of a folder move/delete task.
|
||||
func queryTaskCheck(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
|
||||
status, err := getDriveTaskCheckStatus(runtime, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"scenario": "task_check",
|
||||
"task_id": status.TaskID,
|
||||
"status": status.StatusLabel(),
|
||||
"ready": status.Ready(),
|
||||
"failed": status.Failed(),
|
||||
}, nil
|
||||
}
|
||||
192
shortcuts/drive/drive_task_result_test.go
Normal file
192
shortcuts/drive/drive_task_result_test.go
Normal file
@@ -0,0 +1,192 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestDriveTaskResultValidateErrorsByScenario(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "unsupported scenario",
|
||||
flags: map[string]string{
|
||||
"scenario": "unknown",
|
||||
},
|
||||
wantErr: "unsupported scenario",
|
||||
},
|
||||
{
|
||||
name: "import missing ticket",
|
||||
flags: map[string]string{
|
||||
"scenario": "import",
|
||||
},
|
||||
wantErr: "--ticket is required",
|
||||
},
|
||||
{
|
||||
name: "export missing file token",
|
||||
flags: map[string]string{
|
||||
"scenario": "export",
|
||||
"ticket": "ticket_export_test",
|
||||
},
|
||||
wantErr: "--file-token is required",
|
||||
},
|
||||
{
|
||||
name: "task check missing task id",
|
||||
flags: map[string]string{
|
||||
"scenario": "task_check",
|
||||
},
|
||||
wantErr: "--task-id is required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +task_result"}
|
||||
cmd.Flags().String("scenario", "", "")
|
||||
cmd.Flags().String("ticket", "", "")
|
||||
cmd.Flags().String("task-id", "", "")
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
for key, value := range tt.flags {
|
||||
if err := cmd.Flags().Set(key, value); err != nil {
|
||||
t.Fatalf("set --%s: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
err := DriveTaskResult.Validate(context.Background(), runtime)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveTaskResultDryRunExportIncludesTokenParam(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +task_result"}
|
||||
cmd.Flags().String("scenario", "", "")
|
||||
cmd.Flags().String("ticket", "", "")
|
||||
cmd.Flags().String("task-id", "", "")
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
if err := cmd.Flags().Set("scenario", "export"); err != nil {
|
||||
t.Fatalf("set --scenario: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("ticket", "tk_export"); err != nil {
|
||||
t.Fatalf("set --ticket: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("file-token", "doc_123"); err != nil {
|
||||
t.Fatalf("set --file-token: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
dry := DriveTaskResult.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("expected 1 API call, got %d", len(got.API))
|
||||
}
|
||||
if got.API[0].Params["token"] != "doc_123" {
|
||||
t.Fatalf("export status params = %#v", got.API[0].Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveTaskResultImportIncludesReadyFlags(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/import_tasks/tk_import",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"type": "sheet",
|
||||
"job_status": 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveTaskResult, []string{
|
||||
"+task_result",
|
||||
"--scenario", "import",
|
||||
"--ticket", "tk_import",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) {
|
||||
t.Fatalf("stdout missing ready=false: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"job_status_label": "processing"`)) {
|
||||
t.Fatalf("stdout missing job_status_label: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveTaskResultTaskCheckIncludesReadyFlags(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/task_check",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "pending"},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveTaskResult, []string{
|
||||
"+task_result",
|
||||
"--scenario", "task_check",
|
||||
"--task-id", "task_123",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"status": "pending"`)) {
|
||||
t.Fatalf("stdout missing pending status: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) {
|
||||
t.Fatalf("stdout missing ready=false: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"failed": false`)) {
|
||||
t.Fatalf("stdout missing failed=false: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
@@ -11,5 +11,10 @@ func Shortcuts() []common.Shortcut {
|
||||
DriveUpload,
|
||||
DriveDownload,
|
||||
DriveAddComment,
|
||||
DriveExport,
|
||||
DriveExportDownload,
|
||||
DriveImport,
|
||||
DriveMove,
|
||||
DriveTaskResult,
|
||||
}
|
||||
}
|
||||
|
||||
40
shortcuts/drive/shortcuts_test.go
Normal file
40
shortcuts/drive/shortcuts_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := Shortcuts()
|
||||
want := []string{
|
||||
"+upload",
|
||||
"+download",
|
||||
"+add-comment",
|
||||
"+export",
|
||||
"+export-download",
|
||||
"+import",
|
||||
"+move",
|
||||
"+task_result",
|
||||
}
|
||||
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("len(Shortcuts()) = %d, want %d", len(got), len(want))
|
||||
}
|
||||
|
||||
seen := make(map[string]bool, len(got))
|
||||
for _, shortcut := range got {
|
||||
if seen[shortcut.Command] {
|
||||
t.Fatalf("duplicate shortcut command: %s", shortcut.Command)
|
||||
}
|
||||
seen[shortcut.Command] = true
|
||||
}
|
||||
|
||||
for _, command := range want {
|
||||
if !seen[command] {
|
||||
t.Fatalf("missing shortcut command %q in Shortcuts()", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -876,7 +876,7 @@ func uploadImageToIM(ctx context.Context, runtime *common.RuntimeContext, filePa
|
||||
fd.AddField("image_type", imageType)
|
||||
fd.AddFile("image", f)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
apiResp, err := runtime.DoAPIAsBot(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/im/v1/images",
|
||||
Body: fd,
|
||||
@@ -922,7 +922,7 @@ func uploadFileToIM(ctx context.Context, runtime *common.RuntimeContext, filePat
|
||||
}
|
||||
fd.AddFile("file", f)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
apiResp, err := runtime.DoAPIAsBot(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/im/v1/files",
|
||||
Body: fd,
|
||||
|
||||
@@ -17,10 +17,12 @@ import (
|
||||
var ImMessagesReply = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+messages-reply",
|
||||
Description: "Reply to a message (supports thread replies) with bot identity; bot-only; supports text/markdown/post/media replies, reply-in-thread, idempotency key",
|
||||
Description: "Reply to a message (supports thread replies); user/bot; supports text/markdown/post/media replies, reply-in-thread, idempotency key",
|
||||
Risk: "write",
|
||||
Scopes: []string{"im:message:send_as_bot"},
|
||||
AuthTypes: []string{"bot"},
|
||||
UserScopes: []string{"im:message.send_as_user", "im:message"},
|
||||
BotScopes: []string{"im:message:send_as_bot"},
|
||||
AuthTypes: []string{"bot", "user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "message-id", Desc: "message ID (om_xxx)", Required: true},
|
||||
{Name: "msg-type", Default: "text", Desc: "message type for --content JSON; when using --text/--markdown/--image/--file/--video/--audio, the effective type is inferred automatically", Enum: []string{"text", "post", "image", "file", "audio", "media", "interactive", "share_chat", "share_user"}},
|
||||
|
||||
@@ -18,10 +18,12 @@ import (
|
||||
var ImMessagesSend = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+messages-send",
|
||||
Description: "Send a message to a chat or direct message with bot identity; bot-only; sends to chat-id or user-id with text/markdown/post/media, supports idempotency key",
|
||||
Description: "Send a message to a chat or direct message; user/bot; sends to chat-id or user-id with text/markdown/post/media, supports idempotency key",
|
||||
Risk: "write",
|
||||
Scopes: []string{"im:message:send_as_bot"},
|
||||
AuthTypes: []string{"bot"},
|
||||
UserScopes: []string{"im:message.send_as_user", "im:message"},
|
||||
BotScopes: []string{"im:message:send_as_bot"},
|
||||
AuthTypes: []string{"bot", "user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "chat-id", Desc: "(required, mutually exclusive with --user-id) chat ID (oc_xxx)"},
|
||||
{Name: "user-id", Desc: "(required, mutually exclusive with --chat-id) user open_id (ou_xxx)"},
|
||||
|
||||
@@ -218,24 +218,24 @@ func mailboxPath(mailboxID string, segments ...string) string {
|
||||
}
|
||||
|
||||
// fetchMailboxPrimaryEmail retrieves mailbox primary_email_address from
|
||||
// user_mailboxes.profile. Returns empty string on failure (non-fatal).
|
||||
func fetchMailboxPrimaryEmail(runtime *common.RuntimeContext, mailboxID string) string {
|
||||
// user_mailboxes.profile. Returns the email address or an error.
|
||||
func fetchMailboxPrimaryEmail(runtime *common.RuntimeContext, mailboxID string) (string, error) {
|
||||
if mailboxID == "" {
|
||||
mailboxID = "me"
|
||||
}
|
||||
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "profile"), nil, nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
if email := extractPrimaryEmail(data); email != "" {
|
||||
return email
|
||||
return email, nil
|
||||
}
|
||||
if nested, ok := data["data"].(map[string]interface{}); ok {
|
||||
if email := extractPrimaryEmail(nested); email != "" {
|
||||
return email
|
||||
return email, nil
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return "", fmt.Errorf("profile API returned no primary_email_address")
|
||||
}
|
||||
|
||||
func extractPrimaryEmail(data map[string]interface{}) string {
|
||||
@@ -252,7 +252,8 @@ func extractPrimaryEmail(data map[string]interface{}) string {
|
||||
|
||||
// fetchCurrentUserEmail retrieves the current mailbox primary email.
|
||||
func fetchCurrentUserEmail(runtime *common.RuntimeContext) string {
|
||||
return fetchMailboxPrimaryEmail(runtime, "me")
|
||||
email, _ := fetchMailboxPrimaryEmail(runtime, "me")
|
||||
return email
|
||||
}
|
||||
|
||||
// fetchSelfEmailSet returns a set containing the primary email of the given
|
||||
@@ -264,7 +265,7 @@ func fetchSelfEmailSet(runtime *common.RuntimeContext, mailboxID string) map[str
|
||||
mailboxID = "me"
|
||||
}
|
||||
set := make(map[string]bool)
|
||||
if email := fetchMailboxPrimaryEmail(runtime, mailboxID); email != "" {
|
||||
if email, _ := fetchMailboxPrimaryEmail(runtime, mailboxID); email != "" {
|
||||
set[strings.ToLower(email)] = true
|
||||
}
|
||||
return set
|
||||
@@ -680,6 +681,9 @@ func addUniqueID(dst *[]string, seen map[string]bool, id string) {
|
||||
}
|
||||
|
||||
func listMailboxFolders(runtime *common.RuntimeContext, mailboxID string) ([]folderInfo, error) {
|
||||
if err := validateFolderReadScope(runtime); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "folders"), nil, nil)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("unable to resolve --folder: failed to list folders (%v). %s", err, resolveLookupHint("folder", mailboxID))
|
||||
@@ -701,6 +705,9 @@ func listMailboxFolders(runtime *common.RuntimeContext, mailboxID string) ([]fol
|
||||
}
|
||||
|
||||
func listMailboxLabels(runtime *common.RuntimeContext, mailboxID string) ([]labelInfo, error) {
|
||||
if err := validateLabelReadScope(runtime); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "labels"), nil, nil)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("unable to resolve --label: failed to list labels (%v). %s", err, resolveLookupHint("label", mailboxID))
|
||||
@@ -1882,6 +1889,52 @@ func validateConfirmSendScope(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateFolderReadScope checks that the user's token includes the
|
||||
// mail:user_mailbox.folder:read scope. Called on-demand by listMailboxFolders
|
||||
// before hitting the folders API. System folders are resolved locally and
|
||||
// never reach this check.
|
||||
func validateFolderReadScope(runtime *common.RuntimeContext) error {
|
||||
appID := runtime.Config.AppID
|
||||
userOpenId := runtime.UserOpenId()
|
||||
if appID == "" || userOpenId == "" {
|
||||
return nil
|
||||
}
|
||||
stored := auth.GetStoredToken(appID, userOpenId)
|
||||
if stored == nil {
|
||||
return nil
|
||||
}
|
||||
required := []string{"mail:user_mailbox.folder:read"}
|
||||
if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 {
|
||||
return output.ErrWithHint(output.ExitAuth, "missing_scope",
|
||||
fmt.Sprintf("folder resolution requires scope: %s", strings.Join(missing, ", ")),
|
||||
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to grant folder read permission", strings.Join(missing, " ")))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateLabelReadScope checks that the user's token includes the
|
||||
// mail:user_mailbox.message:modify scope. Called on-demand by listMailboxLabels
|
||||
// before hitting the labels API. System labels are resolved locally and
|
||||
// never reach this check.
|
||||
func validateLabelReadScope(runtime *common.RuntimeContext) error {
|
||||
appID := runtime.Config.AppID
|
||||
userOpenId := runtime.UserOpenId()
|
||||
if appID == "" || userOpenId == "" {
|
||||
return nil
|
||||
}
|
||||
stored := auth.GetStoredToken(appID, userOpenId)
|
||||
if stored == nil {
|
||||
return nil
|
||||
}
|
||||
required := []string{"mail:user_mailbox.message:modify"}
|
||||
if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 {
|
||||
return output.ErrWithHint(output.ExitAuth, "missing_scope",
|
||||
fmt.Sprintf("label resolution requires scope: %s", strings.Join(missing, ", ")),
|
||||
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to grant label access permission", strings.Join(missing, " ")))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateComposeHasAtLeastOneRecipient(to, cc, bcc string) error {
|
||||
if strings.TrimSpace(to) == "" && strings.TrimSpace(cc) == "" && strings.TrimSpace(bcc) == "" {
|
||||
return fmt.Errorf("at least one recipient (--to, --cc, or --bcc) is required")
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/zalando/go-keyring"
|
||||
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -32,6 +33,7 @@ func mailTestConfig() *core.CliConfig {
|
||||
|
||||
func mailShortcutTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
keyring.MockInit() // use in-memory keyring to avoid macOS keychain popups
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
cfg := mailTestConfig()
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
@@ -79,8 +81,8 @@ var MailWatch = common.Shortcut{
|
||||
Command: "+watch",
|
||||
Description: "Watch for incoming mail events via WebSocket (requires scope mail:event and bot event mail.user_mailbox.event.message_received_v1 added). Run with --print-output-schema to see per-format field reference before parsing output.",
|
||||
Risk: "read",
|
||||
Scopes: []string{"mail:event", "mail:user_mailbox.message:readonly", "mail:user_mailbox.folder:read", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Scopes: []string{"mail:event", "mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "format", Default: "data", Desc: "json: NDJSON stream with ok/data envelope; data: bare NDJSON stream"},
|
||||
{Name: "msg-format", Default: "metadata", Desc: "message payload mode: metadata(headers + meta, for triage/notification) | minimal(IDs and state only, no headers, for tracking read/folder changes) | plain_text_full(all metadata fields + full plain-text body) | event(raw WebSocket event, no API call, for debug) | full(full message including HTML body and attachments)"},
|
||||
@@ -138,6 +140,11 @@ var MailWatch = common.Shortcut{
|
||||
Desc(fmt.Sprintf("Subscribe mailbox events (effective_folder_ids=%s, effective_label_ids=%s)", effectiveFolderDisplay, effectiveLabelDisplay)).
|
||||
Body(map[string]interface{}{"event_type": 1})
|
||||
|
||||
if mailbox == "me" {
|
||||
d.GET(mailboxPath("me", "profile")).
|
||||
Desc("Resolve mailbox address for event filtering (requires scope mail:user_mailbox:readonly)")
|
||||
}
|
||||
|
||||
if len(resolvedLabelIDs) > 0 {
|
||||
d.Set("filter_label_ids", strings.Join(resolvedLabelIDs, ","))
|
||||
}
|
||||
@@ -244,11 +251,24 @@ var MailWatch = common.Shortcut{
|
||||
}
|
||||
info("Mailbox subscribed.")
|
||||
|
||||
// mailboxFilter: only apply event-level filtering when an explicit email address is given
|
||||
// "me" is a server-side alias and cannot be matched against event.mail_address
|
||||
mailboxFilter := ""
|
||||
if mailbox != "me" {
|
||||
mailboxFilter = mailbox
|
||||
var unsubOnce sync.Once
|
||||
var unsubErr error
|
||||
unsubscribe := func() error {
|
||||
unsubOnce.Do(func() {
|
||||
_, unsubErr = runtime.CallAPI("POST", mailboxPath(mailbox, "event", "unsubscribe"), nil, map[string]interface{}{"event_type": 1})
|
||||
})
|
||||
return unsubErr
|
||||
}
|
||||
|
||||
// Resolve "me" to the actual email address so we can filter events.
|
||||
mailboxFilter := mailbox
|
||||
if mailbox == "me" {
|
||||
resolved, profileErr := fetchMailboxPrimaryEmail(runtime, "me")
|
||||
if profileErr != nil {
|
||||
unsubscribe() //nolint:errcheck // best-effort cleanup; primary error is profileErr
|
||||
return enhanceProfileError(profileErr)
|
||||
}
|
||||
mailboxFilter = resolved
|
||||
}
|
||||
|
||||
eventCount := 0
|
||||
@@ -257,10 +277,10 @@ var MailWatch = common.Shortcut{
|
||||
// Extract event body
|
||||
eventBody := extractMailEventBody(data)
|
||||
|
||||
// Filter by --mailbox (only when an explicit email address was provided)
|
||||
// Filter by --mailbox
|
||||
if mailboxFilter != "" {
|
||||
mailAddr, _ := eventBody["mail_address"].(string)
|
||||
if mailAddr != mailboxFilter {
|
||||
if !strings.EqualFold(mailAddr, mailboxFilter) {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -414,12 +434,19 @@ var MailWatch = common.Shortcut{
|
||||
}()
|
||||
<-sigCh
|
||||
info(fmt.Sprintf("\nShutting down... (received %d events)", eventCount))
|
||||
info("Unsubscribing mailbox events...")
|
||||
if unsubErr := unsubscribe(); unsubErr != nil {
|
||||
fmt.Fprintf(errOut, "Warning: unsubscribe failed: %v\n", unsubErr)
|
||||
} else {
|
||||
info("Mailbox unsubscribed.")
|
||||
}
|
||||
signal.Stop(sigCh)
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
info("Connected. Waiting for mail events... (Ctrl+C to stop)")
|
||||
if err := cli.Start(ctx); err != nil {
|
||||
unsubscribe() //nolint:errcheck // best-effort cleanup
|
||||
return output.ErrNetwork("WebSocket connection failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
@@ -692,6 +719,25 @@ func wrapWatchSubscribeError(err error) error {
|
||||
return output.ErrWithHint(output.ExitAPI, "api_error", fmt.Sprintf("subscribe mailbox events failed: %v", err), hint)
|
||||
}
|
||||
|
||||
// enhanceProfileError wraps a profile API error with actionable hints.
|
||||
// Permission errors get a scope-specific hint; other errors (network, 5xx)
|
||||
// are reported as-is so diagnostics aren't misleading.
|
||||
func enhanceProfileError(err error) error {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
errType := exitErr.Detail.Type
|
||||
lower := strings.ToLower(exitErr.Detail.Message)
|
||||
if errType == "permission" || errType == "missing_scope" ||
|
||||
strings.Contains(lower, "permission") || strings.Contains(lower, "scope") {
|
||||
return output.ErrWithHint(output.ExitAuth, "missing_scope",
|
||||
"unable to resolve mailbox address: "+exitErr.Detail.Message,
|
||||
"run `lark-cli auth login --scope \"mail:user_mailbox:readonly\"` to grant mailbox profile access")
|
||||
}
|
||||
}
|
||||
// Preserve original error (and its exit code) for non-permission failures.
|
||||
return err
|
||||
}
|
||||
|
||||
// decodeBodyFieldsForFile returns a shallow copy of outputData with body_html and
|
||||
// body_plain_text decoded from base64url, so that files saved via --output-dir contain
|
||||
// human-readable content instead of raw base64 strings.
|
||||
|
||||
@@ -87,8 +87,8 @@ func TestMailWatchDryRunDefaultMetadataFetchesMessage(t *testing.T) {
|
||||
runtime := runtimeForMailWatchTest(t, map[string]string{})
|
||||
|
||||
apis := dryRunAPIsForMailWatchTest(t, MailWatch.DryRun(context.Background(), runtime))
|
||||
if len(apis) != 2 {
|
||||
t.Fatalf("expected 2 dry-run apis, got %d", len(apis))
|
||||
if len(apis) != 3 {
|
||||
t.Fatalf("expected 3 dry-run apis, got %d", len(apis))
|
||||
}
|
||||
if apis[0].Method != "POST" {
|
||||
t.Fatalf("unexpected method: %s", apis[0].Method)
|
||||
@@ -96,10 +96,13 @@ func TestMailWatchDryRunDefaultMetadataFetchesMessage(t *testing.T) {
|
||||
if apis[0].URL != mailboxPath("me", "event", "subscribe") {
|
||||
t.Fatalf("unexpected url: %s", apis[0].URL)
|
||||
}
|
||||
if apis[1].URL != mailboxPath("me", "messages", "{message_id}") {
|
||||
t.Fatalf("unexpected fetch url: %s", apis[1].URL)
|
||||
if apis[1].Method != "GET" || apis[1].URL != mailboxPath("me", "profile") {
|
||||
t.Fatalf("unexpected profile api: %s %s", apis[1].Method, apis[1].URL)
|
||||
}
|
||||
if got := apis[1].Params["format"]; got != "metadata" {
|
||||
if apis[2].URL != mailboxPath("me", "messages", "{message_id}") {
|
||||
t.Fatalf("unexpected fetch url: %s", apis[2].URL)
|
||||
}
|
||||
if got := apis[2].Params["format"]; got != "metadata" {
|
||||
t.Fatalf("unexpected fetch format: %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -110,16 +113,16 @@ func TestMailWatchDryRunMetadataFormatFetchesMessage(t *testing.T) {
|
||||
})
|
||||
|
||||
apis := dryRunAPIsForMailWatchTest(t, MailWatch.DryRun(context.Background(), runtime))
|
||||
if len(apis) != 2 {
|
||||
t.Fatalf("expected 2 dry-run apis, got %d", len(apis))
|
||||
if len(apis) != 3 {
|
||||
t.Fatalf("expected 3 dry-run apis, got %d", len(apis))
|
||||
}
|
||||
if apis[1].Method != "GET" {
|
||||
t.Fatalf("unexpected fetch method: %s", apis[1].Method)
|
||||
if apis[2].Method != "GET" {
|
||||
t.Fatalf("unexpected fetch method: %s", apis[2].Method)
|
||||
}
|
||||
if apis[1].URL != mailboxPath("me", "messages", "{message_id}") {
|
||||
t.Fatalf("unexpected fetch url: %s", apis[1].URL)
|
||||
if apis[2].URL != mailboxPath("me", "messages", "{message_id}") {
|
||||
t.Fatalf("unexpected fetch url: %s", apis[2].URL)
|
||||
}
|
||||
if got := apis[1].Params["format"]; got != "metadata" {
|
||||
if got := apis[2].Params["format"]; got != "metadata" {
|
||||
t.Fatalf("unexpected fetch format: %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -130,10 +133,10 @@ func TestMailWatchDryRunMinimalFormatFetchesMessage(t *testing.T) {
|
||||
})
|
||||
|
||||
apis := dryRunAPIsForMailWatchTest(t, MailWatch.DryRun(context.Background(), runtime))
|
||||
if len(apis) != 2 {
|
||||
t.Fatalf("expected 2 dry-run apis, got %d", len(apis))
|
||||
if len(apis) != 3 {
|
||||
t.Fatalf("expected 3 dry-run apis, got %d", len(apis))
|
||||
}
|
||||
if got := apis[1].Params["format"]; got != "metadata" {
|
||||
if got := apis[2].Params["format"]; got != "metadata" {
|
||||
t.Fatalf("unexpected fetch format: %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -173,10 +176,10 @@ func TestMailWatchDryRunPlainTextFullFormatFetchesMessage(t *testing.T) {
|
||||
})
|
||||
|
||||
apis := dryRunAPIsForMailWatchTest(t, MailWatch.DryRun(context.Background(), runtime))
|
||||
if len(apis) != 2 {
|
||||
t.Fatalf("expected 2 dry-run apis, got %d", len(apis))
|
||||
if len(apis) != 3 {
|
||||
t.Fatalf("expected 3 dry-run apis, got %d", len(apis))
|
||||
}
|
||||
if got := apis[1].Params["format"]; got != "plain_text_full" {
|
||||
if got := apis[2].Params["format"]; got != "plain_text_full" {
|
||||
t.Fatalf("unexpected fetch format: %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -187,10 +190,10 @@ func TestMailWatchDryRunFullFormatUsesFull(t *testing.T) {
|
||||
})
|
||||
|
||||
apis := dryRunAPIsForMailWatchTest(t, MailWatch.DryRun(context.Background(), runtime))
|
||||
if len(apis) != 2 {
|
||||
t.Fatalf("expected 2 dry-run apis, got %d", len(apis))
|
||||
if len(apis) != 3 {
|
||||
t.Fatalf("expected 3 dry-run apis, got %d", len(apis))
|
||||
}
|
||||
if got := apis[1].Params["format"]; got != "full" {
|
||||
if got := apis[2].Params["format"]; got != "full" {
|
||||
t.Fatalf("unexpected fetch format: %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -202,13 +205,13 @@ func TestMailWatchDryRunEventFormatWithLabelFilterFetchesMessage(t *testing.T) {
|
||||
})
|
||||
|
||||
apis := dryRunAPIsForMailWatchTest(t, MailWatch.DryRun(context.Background(), runtime))
|
||||
if len(apis) != 2 {
|
||||
t.Fatalf("expected 2 dry-run apis, got %d", len(apis))
|
||||
if len(apis) != 3 {
|
||||
t.Fatalf("expected 3 dry-run apis, got %d", len(apis))
|
||||
}
|
||||
if apis[1].URL != mailboxPath("me", "messages", "{message_id}") {
|
||||
t.Fatalf("unexpected fetch url: %s", apis[1].URL)
|
||||
if apis[2].URL != mailboxPath("me", "messages", "{message_id}") {
|
||||
t.Fatalf("unexpected fetch url: %s", apis[2].URL)
|
||||
}
|
||||
if got := apis[1].Params["format"]; got != "metadata" {
|
||||
if got := apis[2].Params["format"]; got != "metadata" {
|
||||
t.Fatalf("unexpected fetch format: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
339
shortcuts/minutes/minutes_download.go
Normal file
339
shortcuts/minutes/minutes_download.go
Normal file
@@ -0,0 +1,339 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
// disableClientTimeout removes the global 30s client timeout for large media downloads.
|
||||
// The download is bounded by the caller's context (e.g. Ctrl+C). A fixed timeout
|
||||
// would cut off legitimate large file transfers.
|
||||
disableClientTimeout = 0
|
||||
|
||||
maxBatchSize = 50
|
||||
maxDownloadRedirects = 5
|
||||
)
|
||||
|
||||
// validMinuteToken matches minute tokens: lowercase alphanumeric characters only.
|
||||
var validMinuteToken = regexp.MustCompile(`^[a-z0-9]+$`)
|
||||
|
||||
var MinutesDownload = common.Shortcut{
|
||||
Service: "minutes",
|
||||
Command: "+download",
|
||||
Description: "Download audio/video media file of a minute",
|
||||
Risk: "read",
|
||||
Scopes: []string{"minutes:minutes.media:export"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "minute-tokens", Desc: "minute tokens, comma-separated for batch download (max 50)", Required: true},
|
||||
{Name: "output", Desc: "output path: file path for single token, directory for batch (default: current dir)"},
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
|
||||
{Name: "url-only", Type: "bool", Desc: "only print the download URL(s) without downloading"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
tokens := common.SplitCSV(runtime.Str("minute-tokens"))
|
||||
if len(tokens) == 0 {
|
||||
return output.ErrValidation("--minute-tokens is required")
|
||||
}
|
||||
if len(tokens) > maxBatchSize {
|
||||
return output.ErrValidation("--minute-tokens: too many tokens (%d), maximum is %d", len(tokens), maxBatchSize)
|
||||
}
|
||||
for _, token := range tokens {
|
||||
if !validMinuteToken.MatchString(token) {
|
||||
return output.ErrValidation("invalid minute token %q: must contain only lowercase alphanumeric characters (e.g. obcnq3b9jl72l83w4f149w9c)", token)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
tokens := common.SplitCSV(runtime.Str("minute-tokens"))
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/minutes/v1/minutes/:minute_token/media").
|
||||
Set("minute_tokens", tokens)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
tokens := common.SplitCSV(runtime.Str("minute-tokens"))
|
||||
outputPath := runtime.Str("output")
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
urlOnly := runtime.Bool("url-only")
|
||||
errOut := runtime.IO().ErrOut
|
||||
single := len(tokens) == 1
|
||||
|
||||
// Batch mode: --output must be a directory, not an existing file.
|
||||
if !single && outputPath != "" {
|
||||
if fi, err := os.Stat(outputPath); err == nil && !fi.IsDir() {
|
||||
return output.ErrValidation("--output %q is a file; batch mode expects a directory path", outputPath)
|
||||
}
|
||||
}
|
||||
|
||||
if !single {
|
||||
fmt.Fprintf(errOut, "[minutes +download] batch: %d token(s)\n", len(tokens))
|
||||
}
|
||||
|
||||
type result struct {
|
||||
MinuteToken string `json:"minute_token"`
|
||||
SavedPath string `json:"saved_path,omitempty"`
|
||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
results := make([]result, len(tokens))
|
||||
seen := make(map[string]int)
|
||||
usedNames := make(map[string]bool)
|
||||
|
||||
// Clone the factory client for download use. We clone the struct (not the
|
||||
// pointer) to avoid mutating the shared singleton's Timeout. The original
|
||||
// transport chain is preserved so security headers and test mocks still work.
|
||||
// SSRF protection: ValidateDownloadSourceURL (URL-level) + CheckRedirect
|
||||
// (redirect-level). Transport-level IP check is intentionally omitted because
|
||||
// download URLs originate from the trusted Lark API, not user input.
|
||||
baseClient, err := runtime.Factory.HttpClient()
|
||||
if err != nil {
|
||||
return output.ErrNetwork("failed to get HTTP client: %s", err)
|
||||
}
|
||||
clonedClient := *baseClient
|
||||
clonedClient.Timeout = disableClientTimeout
|
||||
clonedClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= maxDownloadRedirects {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
if len(via) > 0 {
|
||||
prev := via[len(via)-1]
|
||||
if strings.EqualFold(prev.URL.Scheme, "https") && strings.EqualFold(req.URL.Scheme, "http") {
|
||||
return fmt.Errorf("redirect from https to http is not allowed")
|
||||
}
|
||||
}
|
||||
return validate.ValidateDownloadSourceURL(req.Context(), req.URL.String())
|
||||
}
|
||||
dlClient := &clonedClient
|
||||
|
||||
ticker := time.NewTicker(time.Second / 5) // rate-limit to 5 req/s
|
||||
defer ticker.Stop()
|
||||
|
||||
for i, token := range tokens {
|
||||
if i > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
|
||||
if err := validate.ResourceName(token, "--minute-tokens"); err != nil {
|
||||
results[i] = result{MinuteToken: token, Error: err.Error()}
|
||||
continue
|
||||
}
|
||||
if firstIdx, dup := seen[token]; dup {
|
||||
results[i] = result{MinuteToken: token, Error: fmt.Sprintf("duplicate token, same as index %d", firstIdx)}
|
||||
continue
|
||||
}
|
||||
seen[token] = i
|
||||
|
||||
downloadURL, err := fetchDownloadURL(ctx, runtime, token)
|
||||
if err != nil {
|
||||
results[i] = result{MinuteToken: token, Error: err.Error()}
|
||||
continue
|
||||
}
|
||||
|
||||
if urlOnly {
|
||||
results[i] = result{MinuteToken: token, DownloadURL: downloadURL}
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Fprintf(errOut, "Downloading media: %s\n", common.MaskToken(token))
|
||||
|
||||
// single token: --output is a file path; batch: --output is a directory
|
||||
opts := downloadOpts{overwrite: overwrite, usedNames: usedNames}
|
||||
if single {
|
||||
opts.outputPath = outputPath
|
||||
} else {
|
||||
opts.outputDir = outputPath
|
||||
}
|
||||
|
||||
dl, err := downloadMediaFile(ctx, dlClient, downloadURL, token, opts)
|
||||
if err != nil {
|
||||
results[i] = result{MinuteToken: token, Error: err.Error()}
|
||||
continue
|
||||
}
|
||||
results[i] = result{MinuteToken: token, SavedPath: dl.savedPath, SizeBytes: dl.sizeBytes}
|
||||
}
|
||||
|
||||
// output
|
||||
if single {
|
||||
r := results[0]
|
||||
if r.Error != "" {
|
||||
return output.ErrAPI(0, r.Error, nil)
|
||||
}
|
||||
if urlOnly {
|
||||
runtime.Out(map[string]interface{}{"download_url": r.DownloadURL}, nil)
|
||||
} else {
|
||||
runtime.Out(map[string]interface{}{"saved_path": r.SavedPath, "size_bytes": r.SizeBytes}, nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// batch output
|
||||
successCount := 0
|
||||
for _, r := range results {
|
||||
if r.Error == "" {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(errOut, "[minutes +download] done: %d total, %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount)
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{"downloads": results}, &output.Meta{Count: len(results)}, nil)
|
||||
if successCount == 0 && len(results) > 0 {
|
||||
return output.ErrAPI(0, fmt.Sprintf("all %d downloads failed", len(results)), nil)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// fetchDownloadURL retrieves the pre-signed download URL for a minute token.
|
||||
func fetchDownloadURL(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) (string, error) {
|
||||
data, err := runtime.DoAPIJSON(http.MethodGet,
|
||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/media", validate.EncodePathSegment(minuteToken)),
|
||||
nil, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
downloadURL := common.GetString(data, "download_url")
|
||||
if downloadURL == "" {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "API returned empty download_url for %s", minuteToken)
|
||||
}
|
||||
return downloadURL, nil
|
||||
}
|
||||
|
||||
type downloadResult struct {
|
||||
savedPath string
|
||||
sizeBytes int64
|
||||
}
|
||||
|
||||
type downloadOpts struct {
|
||||
outputPath string // explicit output file path (single mode only)
|
||||
outputDir string // output directory (batch mode)
|
||||
overwrite bool
|
||||
usedNames map[string]bool // tracks used filenames to deduplicate in batch mode
|
||||
}
|
||||
|
||||
// downloadMediaFile streams a media file from a pre-signed URL to disk.
|
||||
// Filename resolution: opts.outputPath > Content-Disposition filename > Content-Type ext > <token>.media.
|
||||
func downloadMediaFile(ctx context.Context, client *http.Client, downloadURL, minuteToken string, opts downloadOpts) (*downloadResult, error) {
|
||||
if err := validate.ValidateDownloadSourceURL(ctx, downloadURL); err != nil {
|
||||
return nil, output.ErrValidation("blocked download URL: %s", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
||||
if err != nil {
|
||||
return nil, output.ErrNetwork("invalid download URL: %s", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, output.ErrNetwork("download failed: %s", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if len(body) > 0 {
|
||||
return nil, output.ErrNetwork("download failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil, output.ErrNetwork("download failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// resolve output path
|
||||
outputPath := opts.outputPath
|
||||
if outputPath == "" {
|
||||
filename := resolveFilenameFromResponse(resp, minuteToken)
|
||||
// Deduplicate filenames in batch mode: prefix with token on collision.
|
||||
if opts.usedNames != nil {
|
||||
if opts.usedNames[filename] {
|
||||
filename = minuteToken + "-" + filename
|
||||
}
|
||||
opts.usedNames[filename] = true
|
||||
}
|
||||
outputPath = filepath.Join(opts.outputDir, filename)
|
||||
}
|
||||
|
||||
safePath, err := validate.SafeOutputPath(outputPath)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
if err := common.EnsureWritableFile(safePath, opts.overwrite); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(safePath), 0700); err != nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "api_error", "cannot create parent directory: %s", err)
|
||||
}
|
||||
|
||||
sizeBytes, err := validate.AtomicWriteFromReader(safePath, resp.Body, 0600)
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "api_error", "cannot create file: %s", err)
|
||||
}
|
||||
return &downloadResult{savedPath: safePath, sizeBytes: sizeBytes}, nil
|
||||
}
|
||||
|
||||
// resolveFilenameFromResponse derives the filename from HTTP response headers.
|
||||
// Priority: Content-Disposition filename > Content-Type extension > <token>.media.
|
||||
func resolveFilenameFromResponse(resp *http.Response, minuteToken string) string {
|
||||
if cd := resp.Header.Get("Content-Disposition"); cd != "" {
|
||||
if _, params, err := mime.ParseMediaType(cd); err == nil {
|
||||
if filename := params["filename"]; filename != "" {
|
||||
return filename
|
||||
}
|
||||
}
|
||||
}
|
||||
if ext := extFromContentType(resp.Header.Get("Content-Type")); ext != "" {
|
||||
return minuteToken + ext
|
||||
}
|
||||
return minuteToken + ".media"
|
||||
}
|
||||
|
||||
// preferredExt overrides Go's mime.ExtensionsByType which returns alphabetically sorted
|
||||
// results (e.g. .m4v before .mp4 for video/mp4).
|
||||
var preferredExt = map[string]string{
|
||||
"video/mp4": ".mp4",
|
||||
"audio/mp4": ".m4a",
|
||||
"audio/mpeg": ".mp3",
|
||||
}
|
||||
|
||||
// newDownloadClient wraps the base HTTP client with SSRF protection
|
||||
// (redirect safety + transport-level IP validation). When the base transport
|
||||
// is not *http.Transport (e.g. test mocks), it falls back to cloning
|
||||
// http.DefaultTransport via NewDownloadHTTPClient.
|
||||
// extFromContentType returns a file extension for the given Content-Type, or "" if unknown.
|
||||
func extFromContentType(contentType string) string {
|
||||
if contentType == "" {
|
||||
return ""
|
||||
}
|
||||
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if ext, ok := preferredExt[mediaType]; ok {
|
||||
return ext
|
||||
}
|
||||
if exts, err := mime.ExtensionsByType(mediaType); err == nil && len(exts) > 0 {
|
||||
return exts[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
439
shortcuts/minutes/minutes_download_test.go
Normal file
439
shortcuts/minutes/minutes_download_test.go
Normal file
@@ -0,0 +1,439 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var warmOnce sync.Once
|
||||
|
||||
func warmTokenCache(t *testing.T) {
|
||||
t.Helper()
|
||||
warmOnce.Do(func() {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token", "expire": 7200,
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/warm",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
|
||||
})
|
||||
s := common.Shortcut{
|
||||
Service: "test",
|
||||
Command: "+warm",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := rctx.CallAPI("GET", "/open-apis/test/v1/warm", nil, nil)
|
||||
return err
|
||||
},
|
||||
}
|
||||
parent := &cobra.Command{Use: "test"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs([]string{"+warm"})
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
parent.Execute()
|
||||
})
|
||||
}
|
||||
|
||||
func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
warmTokenCache(t)
|
||||
parent := &cobra.Command{Use: "minutes"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
func defaultConfig() *core.CliConfig {
|
||||
return &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_testuser",
|
||||
}
|
||||
}
|
||||
|
||||
func mediaStub(token, downloadURL string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/minutes/v1/minutes/" + token + "/media",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"download_url": downloadURL},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func downloadStub(url string, body []byte, contentType string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
URL: url,
|
||||
RawBody: body,
|
||||
Headers: http.Header{"Content-Type": []string{contentType}},
|
||||
}
|
||||
}
|
||||
|
||||
// chdir changes the working directory and restores it when the test finishes.
|
||||
func chdir(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
orig, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get cwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("failed to chdir to %s: %v", dir, err)
|
||||
}
|
||||
t.Cleanup(func() { os.Chdir(orig) })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests: resolveOutputFromResponse
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestResolveFilenameFromResponse_ContentDisposition(t *testing.T) {
|
||||
resp := &http.Response{
|
||||
Header: http.Header{
|
||||
"Content-Disposition": []string{`attachment; filename="meeting_recording.mp4"`},
|
||||
"Content-Type": []string{"video/mp4"},
|
||||
},
|
||||
}
|
||||
got := resolveFilenameFromResponse(resp, "tok001")
|
||||
if got != "meeting_recording.mp4" {
|
||||
t.Errorf("expected Content-Disposition filename, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFilenameFromResponse_ContentType(t *testing.T) {
|
||||
resp := &http.Response{
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"video/mp4"},
|
||||
},
|
||||
}
|
||||
got := resolveFilenameFromResponse(resp, "tok001")
|
||||
if !strings.HasPrefix(got, "tok001") {
|
||||
t.Errorf("expected token prefix, got %q", got)
|
||||
}
|
||||
if ext := got[len("tok001"):]; ext == "" {
|
||||
t.Errorf("expected extension after token, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFilenameFromResponse_Fallback(t *testing.T) {
|
||||
resp := &http.Response{Header: http.Header{}}
|
||||
got := resolveFilenameFromResponse(resp, "tok001")
|
||||
if got != "tok001.media" {
|
||||
t.Errorf("expected fallback %q, got %q", "tok001.media", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFilenameFromResponse_InvalidContentDisposition(t *testing.T) {
|
||||
resp := &http.Response{
|
||||
Header: http.Header{
|
||||
"Content-Disposition": []string{"invalid;;;"},
|
||||
"Content-Type": []string{"audio/mpeg"},
|
||||
},
|
||||
}
|
||||
got := resolveFilenameFromResponse(resp, "tok001")
|
||||
if !strings.HasPrefix(got, "tok001") {
|
||||
t.Errorf("expected token prefix from Content-Type fallback, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFilenameFromResponse_EmptyDispositionFilename(t *testing.T) {
|
||||
resp := &http.Response{
|
||||
Header: http.Header{
|
||||
"Content-Disposition": []string{"attachment"},
|
||||
"Content-Type": []string{"video/mp4"},
|
||||
},
|
||||
}
|
||||
got := resolveFilenameFromResponse(resp, "tok001")
|
||||
if got == "" {
|
||||
t.Error("expected non-empty filename")
|
||||
}
|
||||
if !strings.HasPrefix(got, "tok001") {
|
||||
t.Errorf("expected token prefix, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDownload_Validation_NoFlags(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, MinutesDownload, []string{"+download", "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for no flags")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownload_Validation_InvalidToken(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, MinutesDownload, []string{
|
||||
"+download", "--minute-tokens", "obcn***invalid", "--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for invalid token")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid minute token") {
|
||||
t.Errorf("expected 'invalid minute token' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownload_Validation_OutputWithBatch(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, MinutesDownload, []string{
|
||||
"+download", "--minute-tokens", "t1,t2", "--output", "file.mp4", "--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for --output with --minute-tokens")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration tests: single mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDownload_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, MinutesDownload, []string{
|
||||
"+download", "--minute-tokens", "tok001", "--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "media") {
|
||||
t.Errorf("dry-run should show media API path, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "tok001") {
|
||||
t.Errorf("dry-run should show minute_token, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownload_UrlOnly(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(mediaStub("tok001", "https://example.com/presigned/download"))
|
||||
|
||||
err := mountAndRun(t, MinutesDownload, []string{
|
||||
"+download", "--minute-tokens", "tok001", "--url-only", "--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "https://example.com/presigned/download") {
|
||||
t.Errorf("url-only should output download URL, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownload_FullDownload(t *testing.T) {
|
||||
chdir(t, t.TempDir())
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(mediaStub("tok001", "https://example.com/presigned/download"))
|
||||
reg.Register(downloadStub("example.com/presigned/download", []byte("fake-video-content"), "video/mp4"))
|
||||
|
||||
err := mountAndRun(t, MinutesDownload, []string{
|
||||
"+download", "--minute-tokens", "tok001", "--output", "output.mp4", "--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile("output.mp4")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read output file: %v", err)
|
||||
}
|
||||
if string(data) != "fake-video-content" {
|
||||
t.Errorf("file content = %q, want %q", string(data), "fake-video-content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownload_OverwriteProtection(t *testing.T) {
|
||||
chdir(t, t.TempDir())
|
||||
if err := os.WriteFile("existing.mp4", []byte("old"), 0644); err != nil {
|
||||
t.Fatalf("setup failed: %v", err)
|
||||
}
|
||||
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(mediaStub("tok001", "https://example.com/presigned/download"))
|
||||
reg.Register(downloadStub("example.com/presigned/download", []byte("new-content"), "video/mp4"))
|
||||
|
||||
err := mountAndRun(t, MinutesDownload, []string{
|
||||
"+download", "--minute-tokens", "tok001", "--output", "existing.mp4", "--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for existing file without --overwrite")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "exists") {
|
||||
t.Errorf("error should mention file exists, got: %v", err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile("existing.mp4")
|
||||
if string(data) != "old" {
|
||||
t.Errorf("original file should be preserved, got %q", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownload_HttpError(t *testing.T) {
|
||||
chdir(t, t.TempDir())
|
||||
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(mediaStub("tok001", "https://example.com/presigned/download"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "example.com/presigned/download",
|
||||
Status: 403,
|
||||
RawBody: []byte("Forbidden"),
|
||||
})
|
||||
|
||||
err := mountAndRun(t, MinutesDownload, []string{
|
||||
"+download", "--minute-tokens", "tok001", "--output", "output.mp4", "--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for HTTP 403")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "403") {
|
||||
t.Errorf("error should contain status code, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration tests: batch mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDownload_Batch_UrlOnly(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(mediaStub("tok001", "https://example.com/download/1"))
|
||||
reg.Register(mediaStub("tok002", "https://example.com/download/2"))
|
||||
|
||||
err := mountAndRun(t, MinutesDownload, []string{
|
||||
"+download", "--minute-tokens", "tok001,tok002", "--url-only", "--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "download/1") || !strings.Contains(out, "download/2") {
|
||||
t.Errorf("batch url-only should show both URLs, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownload_Batch_Download(t *testing.T) {
|
||||
chdir(t, t.TempDir())
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(mediaStub("tok001", "https://example.com/download/1"))
|
||||
reg.Register(mediaStub("tok002", "https://example.com/download/2"))
|
||||
reg.Register(downloadStub("example.com/download/1", []byte("content-1"), "video/mp4"))
|
||||
reg.Register(downloadStub("example.com/download/2", []byte("content-2"), "video/mp4"))
|
||||
|
||||
err := mountAndRun(t, MinutesDownload, []string{
|
||||
"+download", "--minute-tokens", "tok001,tok002", "--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// verify output structure
|
||||
var result struct {
|
||||
Data struct {
|
||||
Downloads []struct {
|
||||
MinuteToken string `json:"minute_token"`
|
||||
SavedPath string `json:"saved_path"`
|
||||
} `json:"downloads"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
|
||||
t.Fatalf("failed to parse output: %v\nraw: %s", err, stdout.String())
|
||||
}
|
||||
if len(result.Data.Downloads) != 2 {
|
||||
t.Fatalf("expected 2 downloads, got %d", len(result.Data.Downloads))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownload_Batch_PartialFailure(t *testing.T) {
|
||||
chdir(t, t.TempDir())
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(mediaStub("tok001", "https://example.com/download/1"))
|
||||
reg.Register(downloadStub("example.com/download/1", []byte("content-1"), "video/mp4"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/minutes/v1/minutes/tok002/media",
|
||||
Status: 200,
|
||||
Body: map[string]interface{}{
|
||||
"code": 99999, "msg": "permission denied",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, MinutesDownload, []string{
|
||||
"+download", "--minute-tokens", "tok001,tok002", "--as", "bot",
|
||||
}, f, stdout)
|
||||
// partial failure should not cause an overall error
|
||||
if err != nil {
|
||||
t.Fatalf("partial failure should not return error, got: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "tok001") || !strings.Contains(out, "tok002") {
|
||||
t.Errorf("output should contain both tokens, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownload_Batch_DuplicateToken(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
// register media stub only once — dedup means only one API call
|
||||
reg.Register(mediaStub("tok001", "https://example.com/download/1"))
|
||||
|
||||
err := mountAndRun(t, MinutesDownload, []string{
|
||||
"+download", "--minute-tokens", "tok001,tok001", "--url-only", "--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "duplicate") {
|
||||
t.Errorf("second token should report duplicate, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownload_Batch_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, MinutesDownload, []string{
|
||||
"+download", "--minute-tokens", "tok001,tok002", "--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "tok001") || !strings.Contains(out, "tok002") {
|
||||
t.Errorf("dry-run should show tokens, got: %s", out)
|
||||
}
|
||||
}
|
||||
13
shortcuts/minutes/shortcuts.go
Normal file
13
shortcuts/minutes/shortcuts.go
Normal file
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
// Shortcuts returns all minutes shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
MinutesDownload,
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/event"
|
||||
"github.com/larksuite/cli/shortcuts/im"
|
||||
"github.com/larksuite/cli/shortcuts/mail"
|
||||
"github.com/larksuite/cli/shortcuts/minutes"
|
||||
"github.com/larksuite/cli/shortcuts/sheets"
|
||||
"github.com/larksuite/cli/shortcuts/task"
|
||||
"github.com/larksuite/cli/shortcuts/vc"
|
||||
@@ -36,6 +37,7 @@ func init() {
|
||||
allShortcuts = append(allShortcuts, base.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, event.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, mail.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, minutes.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, task.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, vc.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, whiteboard.Shortcuts()...)
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
package shortcuts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -88,3 +92,39 @@ func TestRegisterShortcutsReusesExistingServiceCommand(t *testing.T) {
|
||||
t.Fatal("base workspace shortcut not mounted on existing service command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateShortcutsJSON(t *testing.T) {
|
||||
output := os.Getenv("SHORTCUTS_OUTPUT")
|
||||
if output == "" {
|
||||
t.Skip("set SHORTCUTS_OUTPUT env to generate shortcuts.json")
|
||||
}
|
||||
|
||||
shortcuts := AllShortcuts()
|
||||
|
||||
type entry struct {
|
||||
Verb string `json:"verb"`
|
||||
Description string `json:"description"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
grouped := make(map[string][]entry)
|
||||
for _, s := range shortcuts {
|
||||
verb := strings.TrimPrefix(s.Command, "+")
|
||||
grouped[s.Service] = append(grouped[s.Service], entry{
|
||||
Verb: verb,
|
||||
Description: s.Description,
|
||||
Scopes: s.ScopesForIdentity("user"),
|
||||
})
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(grouped, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal shortcuts: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(output, data, 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
t.Logf("wrote %d bytes to %s", len(data), output)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ var (
|
||||
cellRefPattern = regexp.MustCompile(`^([A-Za-z]+)([1-9][0-9]*)$`)
|
||||
)
|
||||
|
||||
var sheetRangeSeparatorReplacer = strings.NewReplacer(`\!`, "!", `\!`, "!", "!", "!")
|
||||
|
||||
// getFirstSheetID queries the spreadsheet and returns the first sheet's ID.
|
||||
func getFirstSheetID(runtime *common.RuntimeContext, spreadsheetToken string) (string, error) {
|
||||
data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/query", validate.EncodePathSegment(spreadsheetToken)), nil, nil)
|
||||
@@ -56,7 +58,7 @@ func extractSpreadsheetToken(input string) string {
|
||||
}
|
||||
|
||||
func normalizeSheetRange(sheetID, input string) string {
|
||||
input = strings.TrimSpace(input)
|
||||
input = normalizeSheetRangeSeparators(input)
|
||||
if input == "" || strings.Contains(input, "!") || sheetID == "" {
|
||||
return input
|
||||
}
|
||||
@@ -80,7 +82,7 @@ func normalizePointRange(sheetID, input string) string {
|
||||
|
||||
func normalizeWriteRange(sheetID, input string, values interface{}) string {
|
||||
rows, cols := matrixDimensions(values)
|
||||
input = strings.TrimSpace(input)
|
||||
input = normalizeSheetRangeSeparators(input)
|
||||
if input == "" {
|
||||
return buildRectRange(sheetID, "A1", rows, cols)
|
||||
}
|
||||
@@ -97,7 +99,7 @@ func normalizeWriteRange(sheetID, input string, values interface{}) string {
|
||||
}
|
||||
|
||||
func validateSheetRangeInput(sheetID, input string) error {
|
||||
input = strings.TrimSpace(input)
|
||||
input = normalizeSheetRangeSeparators(input)
|
||||
if input == "" || strings.Contains(input, "!") || sheetID != "" {
|
||||
return nil
|
||||
}
|
||||
@@ -108,7 +110,7 @@ func validateSheetRangeInput(sheetID, input string) error {
|
||||
}
|
||||
|
||||
func looksLikeRelativeRange(input string) bool {
|
||||
input = strings.TrimSpace(input)
|
||||
input = normalizeSheetRangeSeparators(input)
|
||||
if input == "" {
|
||||
return false
|
||||
}
|
||||
@@ -120,13 +122,21 @@ func looksLikeRelativeRange(input string) bool {
|
||||
}
|
||||
|
||||
func splitSheetRange(input string) (sheetID, subRange string, ok bool) {
|
||||
parts := strings.SplitN(strings.TrimSpace(input), "!", 2)
|
||||
parts := strings.SplitN(normalizeSheetRangeSeparators(input), "!", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return parts[0], parts[1], true
|
||||
}
|
||||
|
||||
func normalizeSheetRangeSeparators(input string) string {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return input
|
||||
}
|
||||
return sheetRangeSeparatorReplacer.Replace(input)
|
||||
}
|
||||
|
||||
func buildRectRange(sheetID, anchor string, rows, cols int) string {
|
||||
if sheetID == "" {
|
||||
return ""
|
||||
|
||||
148
shortcuts/sheets/sheet_ranges_test.go
Normal file
148
shortcuts/sheets/sheet_ranges_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func mustMarshalSheetsDryRun(t *testing.T, v interface{}) string {
|
||||
t.Helper()
|
||||
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal() error = %v", err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func newSheetsTestRuntime(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
for name := range stringFlags {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for name := range boolFlags {
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
}
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags() error = %v", err)
|
||||
}
|
||||
for name, value := range stringFlags {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("Flags().Set(%q) error = %v", name, err)
|
||||
}
|
||||
}
|
||||
for name, value := range boolFlags {
|
||||
if err := cmd.Flags().Set(name, map[bool]string{true: "true", false: "false"}[value]); err != nil {
|
||||
t.Fatalf("Flags().Set(%q) error = %v", name, err)
|
||||
}
|
||||
}
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
func TestNormalizeSheetRangeSeparators(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{name: "standard", input: "sheet_123!A1:B2", want: "sheet_123!A1:B2"},
|
||||
{name: "escaped ascii", input: `sheet_123\!A1:B2`, want: "sheet_123!A1:B2"},
|
||||
{name: "fullwidth", input: "sheet_123!A1:B2", want: "sheet_123!A1:B2"},
|
||||
{name: "escaped fullwidth", input: `sheet_123\!A1:B2`, want: "sheet_123!A1:B2"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := normalizeSheetRangeSeparators(tt.input); got != tt.want {
|
||||
t.Fatalf("normalizeSheetRangeSeparators(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSheetRangeInputAcceptsEscapedSeparator(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if err := validateSheetRangeInput("", `sheet_123\!A1:B2`); err != nil {
|
||||
t.Fatalf("validateSheetRangeInput() error = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetReadDryRunNormalizesEscapedSeparator(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newSheetsTestRuntime(t, map[string]string{
|
||||
"spreadsheet-token": "sht_test",
|
||||
"range": `sheet_123\!A1`,
|
||||
"sheet-id": "",
|
||||
}, nil)
|
||||
|
||||
got := mustMarshalSheetsDryRun(t, SheetRead.DryRun(context.Background(), runtime))
|
||||
if !strings.Contains(got, `"range":"sheet_123!A1:A1"`) {
|
||||
t.Fatalf("SheetRead.DryRun() = %s, want normalized escaped separator", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetWriteDryRunNormalizesEscapedSeparator(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newSheetsTestRuntime(t, map[string]string{
|
||||
"spreadsheet-token": "sht_test",
|
||||
"range": `sheet_123\!A1:B2`,
|
||||
"values": `[[1,2],[3,4]]`,
|
||||
}, nil)
|
||||
|
||||
got := mustMarshalSheetsDryRun(t, SheetWrite.DryRun(context.Background(), runtime))
|
||||
if !strings.Contains(got, `"range":"sheet_123!A1:B2"`) {
|
||||
t.Fatalf("SheetWrite.DryRun() = %s, want normalized escaped separator", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetAppendDryRunNormalizesEscapedSeparator(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newSheetsTestRuntime(t, map[string]string{
|
||||
"spreadsheet-token": "sht_test",
|
||||
"range": `sheet_123\!A1:B2`,
|
||||
"values": `[["foo","bar"]]`,
|
||||
}, nil)
|
||||
|
||||
got := mustMarshalSheetsDryRun(t, SheetAppend.DryRun(context.Background(), runtime))
|
||||
if !strings.Contains(got, `"range":"sheet_123!A1:B2"`) {
|
||||
t.Fatalf("SheetAppend.DryRun() = %s, want normalized escaped separator", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetFindDryRunNormalizesEscapedSeparator(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newSheetsTestRuntime(t, map[string]string{
|
||||
"spreadsheet-token": "sht_test",
|
||||
"sheet-id": "sheet_123",
|
||||
"find": "target",
|
||||
"range": `sheet_123\!A1:B2`,
|
||||
}, map[string]bool{
|
||||
"ignore-case": false,
|
||||
"match-entire-cell": false,
|
||||
"search-by-regex": false,
|
||||
"include-formulas": false,
|
||||
})
|
||||
|
||||
got := mustMarshalSheetsDryRun(t, SheetFind.DryRun(context.Background(), runtime))
|
||||
if !strings.Contains(got, `"range":"sheet_123!A1:B2"`) {
|
||||
t.Fatalf("SheetFind.DryRun() = %s, want normalized escaped separator", got)
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"code":0,"data":{},"msg":"ok"}
|
||||
@@ -1 +0,0 @@
|
||||
{"code":0,"data":{},"msg":"ok"}
|
||||
@@ -1 +0,0 @@
|
||||
{"code":0,"data":{},"msg":"ok"}
|
||||
@@ -92,6 +92,8 @@ lark-cli mail +reply --message-id <id> --body '收到,谢谢'
|
||||
|
||||
`+message`、`+messages`、`+thread` 默认返回 HTML 正文(`--html=true`)。仅需确认操作结果(如验证标记已读、移动文件夹是否成功)时,用 `--html=false` 跳过 HTML 正文,只返回纯文本,显著减少 token 消耗。
|
||||
|
||||
输出默认为结构化 JSON,可直接读取,无需额外编码转换。
|
||||
|
||||
```bash
|
||||
# ✅ 验证操作结果:不需要 HTML
|
||||
lark-cli mail +message --message-id <id> --html=false
|
||||
|
||||
48
skills/lark-approval/SKILL.md
Normal file
48
skills/lark-approval/SKILL.md
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: lark-approval
|
||||
version: 1.0.0
|
||||
description: "飞书审批 API:审批实例、审批任务管理。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli approval --help"
|
||||
---
|
||||
|
||||
# approval (v4)
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
## API Resources
|
||||
|
||||
```bash
|
||||
lark-cli schema approval.<resource>.<method> # 调用 API 前必须先查看参数结构
|
||||
lark-cli approval <resource> <method> [flags] # 调用 API
|
||||
```
|
||||
|
||||
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
|
||||
|
||||
### instances
|
||||
|
||||
- `get` — 获取单个审批实例详情
|
||||
- `cancel` — 撤回审批实例
|
||||
- `cc` — 抄送审批实例
|
||||
|
||||
### tasks
|
||||
|
||||
- `approve` — 同意审批任务
|
||||
- `reject` — 拒绝审批任务
|
||||
- `transfer` — 转交审批任务
|
||||
- `query` — 查询用户的任务列表
|
||||
|
||||
## 权限表
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|------|-----------|
|
||||
| `instances.get` | `approval:instance:read` |
|
||||
| `instances.cancel` | `approval:instance:write` |
|
||||
| `instances.cc` | `approval:instance:write` |
|
||||
| `tasks.approve` | `approval:task:write` |
|
||||
| `tasks.reject` | `approval:task:write` |
|
||||
| `tasks.transfer` | `approval:task:write` |
|
||||
| `tasks.query` | `approval:task:read` |
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
|
||||
## 命令
|
||||
|
||||
> **关键约束:搜索关键词必须通过 `--query` 传递。**
|
||||
> 正确:`lark-cli docs +search --query "方案"`
|
||||
> 错误:`lark-cli docs +search 方案`
|
||||
> `+search` 不接受“搜索词位置参数”这种写法;如果把关键词直接跟在命令后面,不会进入 `query`,会变成空搜或返回不符合预期的结果。
|
||||
|
||||
```bash
|
||||
# 关键词搜索
|
||||
lark-cli docs +search --query "季度总结"
|
||||
@@ -46,7 +51,7 @@ lark-cli docs +search --query "方案" --format json --page-token '<PAGE_TOKEN>'
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--query <text>` | 否 | 搜索关键词。默认是关键词检索,不是精确标题匹配;不传/空字符串表示空搜 |
|
||||
| `--query <text>` | 否 | 搜索关键词。默认是关键词检索,不是精确标题匹配;不传/空字符串表示空搜。**凡是有关键词,都要显式通过 `--query` 传递,不要写成位置参数。** |
|
||||
| `--filter <json>` | 否 | JSON 对象,会同时应用到 `doc_filter` 与 `wiki_filter` |
|
||||
| `--page-size <n>` | 否 | 每页数量(默认 15,最大 20) |
|
||||
| `--page-token <token>` | 否 | 翻页标记(配合 `has_more` 使用) |
|
||||
@@ -59,6 +64,7 @@ lark-cli docs +search --query "方案" --format json --page-token '<PAGE_TOKEN>'
|
||||
|
||||
## 决策规则
|
||||
|
||||
- 参数传递:只要用户给了搜索关键词,就必须显式使用 `--query "<关键词>"`。不要生成 `lark-cli docs +search 方案`、`lark-cli docs +search xxx(搜索关键词)` 这种位置参数写法。
|
||||
- 查询语义:默认按关键词搜索理解。用户说“标题为 `X`”“标题里有 `X`”“搜索 `X` 文档”时,先直接返回命中的 OpenAPI 结果;只有用户明确要求“标题精确等于 `X`”时,才做客户端二次筛选。做精确匹配前,先去掉 `title_highlighted` 里的高亮标签。
|
||||
- 入口选择:用户说“找表格标题”“找名为 `X` 的电子表格”“搜某个报表”时,也默认走 `docs +search`。不要误用 `sheets +find` 做跨文件搜索。
|
||||
- 分页策略:默认只返回**第一页**,并说明 `has_more` / `page_token`。只有当用户明确要求“全部结果”“继续翻页”“全量扫描”“所有结果”“完整列表”时,才继续翻页。
|
||||
|
||||
@@ -164,6 +164,11 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| [`+upload`](references/lark-drive-upload.md) | Upload a local file to Drive |
|
||||
| [`+download`](references/lark-drive-download.md) | Download a file from Drive to local |
|
||||
| [`+add-comment`](references/lark-drive-add-comment.md) | Add a full-document comment, or a local comment to selected docx text (also supports wiki URL resolving to doc/docx) |
|
||||
| [`+export`](references/lark-drive-export.md) | Export a doc/docx/sheet/bitable to a local file with limited polling |
|
||||
| [`+export-download`](references/lark-drive-export-download.md) | Download an exported file by file_token |
|
||||
| [`+import`](references/lark-drive-import.md) | Import a local file to Drive as a cloud document (docx, sheet, bitable) |
|
||||
| [`+move`](references/lark-drive-move.md) | Move a file or folder to another location in Drive |
|
||||
| [`+task_result`](references/lark-drive-task-result.md) | Poll async task result for import, export, move, or delete operations |
|
||||
|
||||
## API Resources
|
||||
|
||||
@@ -177,6 +182,8 @@ lark-cli drive <resource> <method> [flags] # 调用 API
|
||||
### files
|
||||
|
||||
- `copy` — 复制文件
|
||||
- `create_folder` — 新建文件夹
|
||||
- `list` — 获取文件夹下的清单
|
||||
|
||||
### file.comments
|
||||
|
||||
@@ -208,11 +215,21 @@ lark-cli drive <resource> <method> [flags] # 调用 API
|
||||
- `subscription` — 订阅用户、应用维度事件(本次开放评论添加事件)
|
||||
- `subscription_status` — 查询用户、应用对指定事件的订阅状态
|
||||
|
||||
### file.statistics
|
||||
|
||||
- `get` — 获取文件统计信息
|
||||
|
||||
### file.view_records
|
||||
|
||||
- `list` — 获取文档的访问者记录
|
||||
|
||||
## 权限表
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|------|-----------|
|
||||
| `files.copy` | `docs:document:copy` |
|
||||
| `files.create_folder` | `space:folder:create` |
|
||||
| `files.list` | `space:document:retrieve` |
|
||||
| `file.comments.batch_query` | `docs:document.comment:read` |
|
||||
| `file.comments.create_v2` | `docs:document.comment:create` |
|
||||
| `file.comments.list` | `docs:document.comment:read` |
|
||||
@@ -228,4 +245,5 @@ lark-cli drive <resource> <method> [flags] # 调用 API
|
||||
| `user.remove_subscription` | `docs:event:subscribe` |
|
||||
| `user.subscription` | `docs:event:subscribe` |
|
||||
| `user.subscription_status` | `docs:event:subscribe` |
|
||||
|
||||
| `file.statistics.get` | `drive:drive.metadata:readonly` |
|
||||
| `file.view_records.list` | `drive:file:view_record:readonly` |
|
||||
|
||||
50
skills/lark-drive/references/lark-drive-export-download.md
Normal file
50
skills/lark-drive/references/lark-drive-export-download.md
Normal file
@@ -0,0 +1,50 @@
|
||||
|
||||
# drive +export-download
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
根据导出任务产物的 `file_token` 下载本地文件。通常与 `drive +task_result --scenario export` 配合使用。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 使用服务端返回的文件名下载到当前目录
|
||||
lark-cli drive +export-download \
|
||||
--file-token "<EXPORTED_FILE_TOKEN>"
|
||||
|
||||
# 下载到指定目录
|
||||
lark-cli drive +export-download \
|
||||
--file-token "<EXPORTED_FILE_TOKEN>" \
|
||||
--output-dir ./exports
|
||||
|
||||
# 指定本地文件名
|
||||
lark-cli drive +export-download \
|
||||
--file-token "<EXPORTED_FILE_TOKEN>" \
|
||||
--file-name "weekly-report.pdf" \
|
||||
--output-dir ./exports
|
||||
|
||||
# 允许覆盖
|
||||
lark-cli drive +export-download \
|
||||
--file-token "<EXPORTED_FILE_TOKEN>" \
|
||||
--overwrite
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--file-token` | 是 | 导出完成后的产物 token |
|
||||
| `--file-name` | 否 | 覆盖默认文件名 |
|
||||
| `--output-dir` | 否 | 本地输出目录,默认当前目录 |
|
||||
| `--overwrite` | 否 | 覆盖已存在文件 |
|
||||
|
||||
## 使用顺序
|
||||
|
||||
1. 用 `drive +export` 发起导出
|
||||
2. 如果返回 `ticket` / `next_command`,用 `drive +task_result --scenario export --ticket <ticket> --file-token <source_token>` 继续查
|
||||
3. 查到 `file_token` 后,用 `drive +export-download` 下载
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-drive](../SKILL.md) -- 云空间全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
100
skills/lark-drive/references/lark-drive-export.md
Normal file
100
skills/lark-drive/references/lark-drive-export.md
Normal file
@@ -0,0 +1,100 @@
|
||||
|
||||
# drive +export
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
把 `doc` / `docx` / `sheet` / `bitable` 导出到本地文件。这个 shortcut 内置有限轮询:
|
||||
|
||||
- 如果导出任务在轮询窗口内完成,会直接下载到本地目录
|
||||
- 如果轮询结束仍未完成,会返回 `ticket`、`ready=false`、`timed_out=true` 和 `next_command`
|
||||
- 后续继续查结果时,改用 `drive +task_result --scenario export`
|
||||
- 拿到 `file_token` 后,改用 `drive +export-download`
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 导出新版文档为 pdf,默认保存到当前目录
|
||||
lark-cli drive +export \
|
||||
--token "<DOCX_TOKEN>" \
|
||||
--doc-type docx \
|
||||
--file-extension pdf
|
||||
|
||||
# 导出旧版文档为 docx
|
||||
lark-cli drive +export \
|
||||
--token "<DOC_TOKEN>" \
|
||||
--doc-type doc \
|
||||
--file-extension docx
|
||||
|
||||
# 导出 docx 为 markdown
|
||||
# 注意:markdown 只支持 docx,底层走 /open-apis/docs/v1/content
|
||||
lark-cli drive +export \
|
||||
--token "<DOCX_TOKEN>" \
|
||||
--doc-type docx \
|
||||
--file-extension markdown
|
||||
|
||||
# 导出电子表格为 xlsx
|
||||
lark-cli drive +export \
|
||||
--token "<SHEET_TOKEN>" \
|
||||
--doc-type sheet \
|
||||
--file-extension xlsx \
|
||||
--output-dir ./exports
|
||||
|
||||
# 导出电子表格或多维表格为 csv 时,必须传 sub_id
|
||||
lark-cli drive +export \
|
||||
--token "<SHEET_OR_BITABLE_TOKEN>" \
|
||||
--doc-type "<sheet|bitable>" \
|
||||
--file-extension csv \
|
||||
--sub-id "<SUB_ID>" \
|
||||
--output-dir ./exports
|
||||
|
||||
# 允许覆盖已存在文件
|
||||
lark-cli drive +export \
|
||||
--token "<DOCX_TOKEN>" \
|
||||
--doc-type docx \
|
||||
--file-extension pdf \
|
||||
--overwrite
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--token` | 是 | 源文档 token |
|
||||
| `--doc-type` | 是 | 源文档类型:`doc` / `docx` / `sheet` / `bitable` |
|
||||
| `--file-extension` | 是 | 导出格式:`docx` / `pdf` / `xlsx` / `csv` / `markdown` |
|
||||
| `--sub-id` | 条件必填 | 当 `sheet` / `bitable` 导出为 `csv` 时必填 |
|
||||
| `--output-dir` | 否 | 本地输出目录,默认当前目录 |
|
||||
| `--overwrite` | 否 | 覆盖已存在文件 |
|
||||
|
||||
## 关键约束
|
||||
|
||||
- `markdown` 只支持 `docx`
|
||||
- `sheet` / `bitable` 导出为 `csv` 时必须带 `--sub-id`
|
||||
- shortcut 内部固定有限轮询:最多 10 次,每次间隔 5 秒
|
||||
- 轮询超时不是失败;会返回 `ticket`、`timed_out=true` 和 `next_command`,供后续继续查询
|
||||
|
||||
## 推荐续跑方式
|
||||
|
||||
```bash
|
||||
# 第一步:先尝试直接导出
|
||||
lark-cli drive +export \
|
||||
--token "<DOCX_TOKEN>" \
|
||||
--doc-type docx \
|
||||
--file-extension pdf
|
||||
|
||||
# 如果返回 ready=false / timed_out=true,再继续查
|
||||
lark-cli drive +task_result \
|
||||
--scenario export \
|
||||
--ticket "<TICKET>" \
|
||||
--file-token "<DOCX_TOKEN>"
|
||||
|
||||
# 查到 file_token 后下载
|
||||
lark-cli drive +export-download \
|
||||
--file-token "<EXPORTED_FILE_TOKEN>" \
|
||||
--output-dir ./exports
|
||||
```
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-drive](../SKILL.md) -- 云空间全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
102
skills/lark-drive/references/lark-drive-import.md
Normal file
102
skills/lark-drive/references/lark-drive-import.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# drive +import
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
将本地文件(如 Word、TXT、Markdown、Excel 等)导入并转换为飞书在线云文档(docx、sheet、bitable)。底层统一通过 `POST /open-apis/drive/v1/import_tasks` 接口创建导入任务,并在 shortcut 内做有限次数轮询 `GET /open-apis/drive/v1/import_tasks/:ticket`。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 导入 Markdown 为新版文档 (docx)
|
||||
lark-cli drive +import --file ./README.md --type docx
|
||||
|
||||
# 导入 Excel 为电子表格 (sheet)
|
||||
lark-cli drive +import --file ./data.xlsx --type sheet
|
||||
|
||||
# 导入到指定文件夹,并指定导入后的文件名
|
||||
lark-cli drive +import --file ./data.csv --type bitable --folder-token <FOLDER_TOKEN> --name "导入数据表"
|
||||
|
||||
# 预览底层调用链(上传 -> 创建任务 -> 轮询)
|
||||
lark-cli drive +import --file ./README.md --type docx --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--file` | 是 | 本地文件路径,根据文件后缀名自动推断 `file_extension`;文件需满足对应格式的导入大小限制,超过 20MB 且仍在允许范围内时会自动切换分片上传 |
|
||||
| `--type` | 是 | 导入目标云文档格式。可选值:`docx` (新版文档)、`sheet` (电子表格)、`bitable` (多维表格) |
|
||||
| `--folder-token` | 否 | 目标文件夹 token,不传则请求中的 `point.mount_key` 为空字符串,Import API 会将其解释为导入到云空间根目录 |
|
||||
| `--name` | 否 | 导入后的在线云文档名称,不传默认使用本地文件名去掉扩展名后的结果 |
|
||||
|
||||
## 行为说明
|
||||
|
||||
- **完整执行流程**:此 shortcut 内部封装了完整流程:
|
||||
1. 自动上传源文件获取 `file_token`:
|
||||
- 20MB 及以下:调用素材上传接口 `POST /open-apis/drive/v1/medias/upload_all`
|
||||
- 超过 20MB:自动切换为分片上传 `upload_prepare -> upload_part -> upload_finish`
|
||||
2. 调用 `import_tasks` 接口发起导入任务,自动根据本地文件提取扩展名并构造挂载点(`mount_point`)参数
|
||||
3. 自动轮询查询导入任务状态;如果在内置轮询窗口内完成,则直接返回导入结果;如果仍未完成,则返回 `ticket`、当前状态和后续查询命令
|
||||
- **默认根目录行为**:不传 `--folder-token` 时,shortcut 会保留空的 `point.mount_key`,Lark Import API 会将其视为“导入到调用者根目录”。
|
||||
|
||||
### 支持的文件类型转换
|
||||
|
||||
本地文件扩展名与目标云文档类型的对应关系如下:
|
||||
|
||||
| 本地文件扩展名 | 可导入为 | 说明 |
|
||||
|--------------|---------|------|
|
||||
| `.docx`, `.doc` | `docx` | Microsoft Word 文档 |
|
||||
| `.txt` | `docx` | 纯文本文件 |
|
||||
| `.md`, `.markdown`, `.mark` | `docx` | Markdown 文档 |
|
||||
| `.html` | `docx` | HTML 文档 |
|
||||
| `.xlsx` | `sheet`, `bitable` | Microsoft Excel 表格 |
|
||||
| `.xls` | `sheet` | Microsoft Excel 97-2003 表格 |
|
||||
| `.csv` | `sheet`, `bitable` | CSV 数据文件 |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 文件扩展名与目标文档类型必须匹配,否则会返回验证错误:
|
||||
> - 文档类文件(.docx, .doc, .txt, .md, .html)**只能**导入为 `docx`
|
||||
> - `.xlsx` / `.csv` 文件**只能**导入为 `sheet` 或 `bitable`
|
||||
> - `.xls` 文件**只能**导入为 `sheet`
|
||||
> - 例如:`.csv` 文件不能导入为 `docx`,`.md` 文件不能导入为 `sheet`
|
||||
|
||||
### 文件大小限制
|
||||
|
||||
除扩展名与目标类型匹配外,`drive +import` 还会在本地上传前校验格式级大小限制:
|
||||
|
||||
| 本地文件扩展名 | 导入目标 | 大小上限 |
|
||||
|--------------|---------|---------|
|
||||
| `.docx`, `.doc` | `docx` | 600MB |
|
||||
| `.txt` | `docx` | 20MB |
|
||||
| `.md`, `.mark`, `.markdown` | `docx` | 20MB |
|
||||
| `.html` | `docx` | 20MB |
|
||||
| `.xlsx` | `sheet`, `bitable` | 800MB |
|
||||
| `.csv` | `sheet` | 20MB |
|
||||
| `.csv` | `bitable` | 100MB |
|
||||
| `.xls` | `sheet` | 20MB |
|
||||
|
||||
- 如果文件超出对应上限,shortcut 会在真正上传前直接返回验证错误。
|
||||
- “超过 20MB 自动切换分片上传”只表示上传链路会切到 multipart,不代表所有格式都允许导入超过 20MB 的文件。
|
||||
|
||||
- 若导入任务执行失败,会返回失败时的 `job_status` 及错误信息。
|
||||
- 若内置轮询超时但任务仍在处理中,shortcut 会成功返回,并带上:
|
||||
- `ready=false`
|
||||
- `timed_out=true`
|
||||
- `next_command`:可直接复制执行的后续查询命令,例如 `lark-cli drive +task_result --scenario import --ticket <TICKET>`
|
||||
- 如果文件扩展名不被支持,执行时将抛出验证错误。
|
||||
|
||||
### 超时后的继续查询
|
||||
|
||||
当 `+import` 的内置轮询窗口结束但任务尚未完成时,使用返回结果中的 `ticket` 继续查询:
|
||||
|
||||
```bash
|
||||
lark-cli drive +task_result --scenario import --ticket <TICKET>
|
||||
```
|
||||
|
||||
> [!CAUTION]
|
||||
> `drive +import` 是**写入操作** —— 执行前必须确认用户意图。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-drive](../SKILL.md) -- 云空间全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
92
skills/lark-drive/references/lark-drive-move.md
Normal file
92
skills/lark-drive/references/lark-drive-move.md
Normal file
@@ -0,0 +1,92 @@
|
||||
|
||||
# drive +move
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
将文件或文件夹移动到用户云空间的其他位置。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 移动文件到指定文件夹
|
||||
lark-cli drive +move \
|
||||
--file-token <FILE_TOKEN> \
|
||||
--type file \
|
||||
--folder-token <TARGET_FOLDER_TOKEN>
|
||||
|
||||
# 移动文档到指定文件夹
|
||||
lark-cli drive +move \
|
||||
--file-token <DOCX_TOKEN> \
|
||||
--type docx \
|
||||
--folder-token <TARGET_FOLDER_TOKEN>
|
||||
|
||||
# 移动文件夹(异步操作,会自动有限轮询任务状态)
|
||||
lark-cli drive +move \
|
||||
--file-token <FOLDER_TOKEN> \
|
||||
--type folder \
|
||||
--folder-token <TARGET_FOLDER_TOKEN>
|
||||
|
||||
# 移动到根文件夹(不指定 --folder-token)
|
||||
lark-cli drive +move \
|
||||
--file-token <FILE_TOKEN> \
|
||||
--type file
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--file-token` | 是 | 需要移动的文件或文件夹 token |
|
||||
| `--type` | 是 | 文件类型,可选值:`file` (普通文件)、`docx` (新版文档)、`bitable` (多维表格)、`doc` (旧版文档)、`sheet` (电子表格)、`mindnote` (思维笔记)、`folder` (文件夹)、`slides` (幻灯片) |
|
||||
| `--folder-token` | 否 | 目标文件夹 token,不指定则移动到根文件夹 |
|
||||
|
||||
## 文件类型说明
|
||||
|
||||
| 类型 | 说明 |
|
||||
|------|------|
|
||||
| `file` | 普通文件 |
|
||||
| `docx` | 新版云文档 |
|
||||
| `doc` | 旧版云文档 |
|
||||
| `sheet` | 电子表格 |
|
||||
| `bitable` | 多维表格 |
|
||||
| `mindnote` | 思维笔记 |
|
||||
| `slides` | 幻灯片 |
|
||||
| `folder` | 文件夹(移动文件夹是异步操作) |
|
||||
|
||||
## 行为说明
|
||||
|
||||
- **普通文件移动**:同步操作,立即完成
|
||||
- **文件夹移动**:异步操作,接口返回 `task_id`,shortcut 会先做有限轮询;如果在轮询窗口内完成,则直接返回成功结果
|
||||
- **轮询超时不是失败**:文件夹移动内置最多轮询 30 次、每次间隔 2 秒;如果轮询结束任务仍未完成,会返回 `task_id`、`status`、`ready=false`、`timed_out=true` 和 `next_command`
|
||||
- **继续查询**:当看到 `next_command` 时,改用 `lark-cli drive +task_result --scenario task_check --task-id <TASK_ID>` 继续查询
|
||||
- **目标文件夹**:如果不指定 `--folder-token`,文件将被移动到用户的根文件夹("我的空间")
|
||||
- **权限要求**:需要被移动文件的可管理权限、被移动文件所在位置的编辑权限、目标位置的编辑权限
|
||||
|
||||
## 推荐续跑方式
|
||||
|
||||
```bash
|
||||
# 第一步:先直接移动文件夹
|
||||
lark-cli drive +move \
|
||||
--file-token <FOLDER_TOKEN> \
|
||||
--type folder \
|
||||
--folder-token <TARGET_FOLDER_TOKEN>
|
||||
|
||||
# 如果返回 ready=false / timed_out=true,再继续查
|
||||
lark-cli drive +task_result \
|
||||
--scenario task_check \
|
||||
--task-id <TASK_ID>
|
||||
```
|
||||
|
||||
## 限制
|
||||
|
||||
- 被移动的文件不支持 wiki 文档
|
||||
- 该接口不支持并发调用
|
||||
- 调用频率上限为 5 QPS 且 10000 次/天
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**写入操作** —— 执行前必须确认用户意图。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-drive](../SKILL.md) -- 云空间全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
170
skills/lark-drive/references/lark-drive-task-result.md
Normal file
170
skills/lark-drive/references/lark-drive-task-result.md
Normal file
@@ -0,0 +1,170 @@
|
||||
|
||||
# drive +task_result
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
查询异步任务结果。该 shortcut 聚合了导入、导出、移动/删除文件夹等多种异步任务的结果查询,统一接口方便调用。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 查询导入任务结果
|
||||
lark-cli drive +task_result \
|
||||
--scenario import \
|
||||
--ticket <IMPORT_TICKET>
|
||||
|
||||
# 查询导出任务结果
|
||||
lark-cli drive +task_result \
|
||||
--scenario export \
|
||||
--ticket <EXPORT_TICKET> \
|
||||
--file-token <SOURCE_DOC_TOKEN>
|
||||
|
||||
# 查询移动/删除文件夹任务状态
|
||||
lark-cli drive +task_result \
|
||||
--scenario task_check \
|
||||
--task-id <TASK_ID>
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--scenario` | 是 | 任务场景,可选值:`import` (导入任务)、`export` (导出任务)、`task_check` (移动/删除文件夹任务) |
|
||||
| `--ticket` | 条件必填 | 异步任务 ticket,**import/export 场景必填** |
|
||||
| `--task-id` | 条件必填 | 异步任务 ID,**task_check 场景必填** |
|
||||
| `--file-token` | 条件必填 | 导出任务对应的源文档 token,**export 场景必填** |
|
||||
|
||||
## 场景说明
|
||||
|
||||
| 场景 | 说明 | 所需参数 |
|
||||
|------|------|----------|
|
||||
| `import` | 文档导入任务(如将本地文件导入为云文档) | `--ticket` |
|
||||
| `export` | 文档导出任务(如云文档导出为 PDF/Word) | `--ticket`、`--file-token` |
|
||||
| `task_check` | 文件夹移动/删除任务 | `--task-id` |
|
||||
|
||||
## 返回结果
|
||||
|
||||
### Import 场景返回
|
||||
|
||||
```json
|
||||
{
|
||||
"scenario": "import",
|
||||
"ticket": "<IMPORT_TICKET>",
|
||||
"type": "sheet",
|
||||
"ready": true,
|
||||
"failed": false,
|
||||
"job_status": 0,
|
||||
"job_status_label": "success",
|
||||
"job_error_msg": "success",
|
||||
"token": "<IMPORTED_DOC_TOKEN>",
|
||||
"url": "https://example.feishu.cn/sheets/<IMPORTED_DOC_TOKEN>",
|
||||
"extra": ["2000"]
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明:**
|
||||
- `ready`: 是否已经导入完成,可直接使用 `token` / `url`
|
||||
- `failed`: 是否已经失败
|
||||
- `job_status`: 服务端返回的原始状态码
|
||||
- `job_status_label`: 便于阅读的状态标签,例如 `success` / `processing`
|
||||
- `token`: 导入后的文档 token
|
||||
- `url`: 导入后的文档链接
|
||||
|
||||
### Export 场景返回
|
||||
|
||||
```json
|
||||
{
|
||||
"scenario": "export",
|
||||
"ticket": "<EXPORT_TICKET>",
|
||||
"ready": true,
|
||||
"failed": false,
|
||||
"file_extension": "pdf",
|
||||
"type": "doc",
|
||||
"file_name": "docName",
|
||||
"file_token": "<EXPORTED_FILE_TOKEN>",
|
||||
"file_size": 34356,
|
||||
"job_error_msg": "success",
|
||||
"job_status": 0,
|
||||
"job_status_label": "success"
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明:**
|
||||
- `ready`: 是否已经完成导出,可直接使用 `file_token`
|
||||
- `failed`: 是否已经失败
|
||||
- `job_status`: 服务端返回的原始状态码
|
||||
- `job_status_label`: 便于阅读的状态标签,例如 `success` / `processing`
|
||||
- `file_token`: 导出文件的 token,用于下载
|
||||
- `file_extension`: 导出文件扩展名
|
||||
- `file_size`: 导出文件大小(字节)
|
||||
|
||||
### Task_check 场景返回
|
||||
|
||||
```json
|
||||
{
|
||||
"scenario": "task_check",
|
||||
"task_id": "<TASK_ID>",
|
||||
"status": "success",
|
||||
"ready": true,
|
||||
"failed": false
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明:**
|
||||
- `status`: 任务状态,`success`=成功,`failed`=失败,`pending`=处理中
|
||||
- `ready`: 是否已经完成
|
||||
- `failed`: 是否已经失败
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 配合 +import 使用
|
||||
|
||||
```bash
|
||||
# 1. 创建导入任务
|
||||
lark-cli drive +import --file ./data.xlsx --type sheet
|
||||
# 若任务很快完成:直接返回 token / url
|
||||
# 若内置轮询超时:返回 ready=false、ticket 和 next_command
|
||||
|
||||
# 2. 轮询导入结果
|
||||
lark-cli drive +task_result --scenario import --ticket <IMPORT_TICKET>
|
||||
```
|
||||
|
||||
### 配合 +move 使用
|
||||
|
||||
```bash
|
||||
# 1. 移动文件夹(异步操作)
|
||||
lark-cli drive +move --file-token <FOLDER_TOKEN> --type folder --folder-token <TARGET_FOLDER_TOKEN>
|
||||
# 若轮询窗口内完成:直接返回 ready=true
|
||||
# 若内置轮询结束仍未完成:返回 ready=false、task_id 和 next_command
|
||||
|
||||
# 2. 轮询移动结果
|
||||
lark-cli drive +task_result --scenario task_check --task-id <TASK_ID>
|
||||
```
|
||||
|
||||
### 配合 +export 使用
|
||||
|
||||
```bash
|
||||
# 1. 发起导出
|
||||
lark-cli drive +export --token <SOURCE_DOC_TOKEN> --doc-type docx --file-extension pdf
|
||||
# 若轮询窗口内完成:直接下载本地文件
|
||||
# 若内置轮询结束仍未完成:返回 ready=false、ticket 和 next_command
|
||||
|
||||
# 2. 继续查询导出结果
|
||||
lark-cli drive +task_result --scenario export --ticket <EXPORT_TICKET> --file-token <SOURCE_DOC_TOKEN>
|
||||
|
||||
# 3. 拿到 file_token 后下载
|
||||
lark-cli drive +export-download --file-token <EXPORTED_FILE_TOKEN>
|
||||
```
|
||||
|
||||
## 权限要求
|
||||
|
||||
| 场景 | 所需 scope |
|
||||
|------|-----------|
|
||||
| import | `drive:drive.metadata:readonly` |
|
||||
| export | `drive:drive.metadata:readonly` |
|
||||
| task_check | `drive:drive.metadata:readonly` |
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-drive](../SKILL.md) -- 云空间全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
@@ -61,10 +61,10 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
|
||||
| [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by keyword and/or member open_ids (e.g. look up chat_id by group name); user/bot; supports member/type filters, sorting, and pagination |
|
||||
| [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description; user/bot; updates a chat's name or description |
|
||||
| [`+messages-mget`](references/lark-im-messages-mget.md) | Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies |
|
||||
| [`+messages-reply`](references/lark-im-messages-reply.md) | Reply to a message (supports thread replies) with bot identity; bot-only; supports text/markdown/post/media replies, reply-in-thread, idempotency key |
|
||||
| [`+messages-reply`](references/lark-im-messages-reply.md) | Reply to a message (supports thread replies); user/bot; supports text/markdown/post/media replies, reply-in-thread, idempotency key |
|
||||
| [`+messages-resources-download`](references/lark-im-messages-resources-download.md) | Download images/files from a message; user/bot; downloads image/file resources by message-id and file-key to a safe relative output path |
|
||||
| [`+messages-search`](references/lark-im-messages-search.md) | Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, supports auto-pagination via `--page-all` / `--page-limit`, enriches results via batched mget and chats batch_query |
|
||||
| [`+messages-send`](references/lark-im-messages-send.md) | Send a message to a chat or direct message with bot identity; bot-only; sends to chat-id or user-id with text/markdown/post/media, supports idempotency key |
|
||||
| [`+messages-send`](references/lark-im-messages-send.md) | Send a message to a chat or direct message; user/bot; sends to chat-id or user-id with text/markdown/post/media, supports idempotency key |
|
||||
| [`+threads-messages-list`](references/lark-im-threads-messages-list.md) | List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination |
|
||||
|
||||
## API Resources
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
|
||||
|
||||
Reply to a specific message. Only supports bot identity. Also supports thread replies.
|
||||
Reply to a specific message. Supports both user identity (`--as user`) and bot identity (`--as bot`). Also supports thread replies.
|
||||
|
||||
This skill maps to the shortcut: `lark-cli im +messages-reply` (internally calls `POST /open-apis/im/v1/messages/:message_id/reply`).
|
||||
|
||||
@@ -12,12 +12,14 @@ Replies sent by this tool are visible to other people. Before calling it, you **
|
||||
|
||||
1. Which message to reply to
|
||||
2. The reply content
|
||||
3. Which identity to use (bot only)
|
||||
3. Which identity to use (user or bot)
|
||||
|
||||
**Do not** send a reply without explicit user approval.
|
||||
|
||||
When using `--as bot`, the reply is sent in the app's name, so make sure the app has already been added to the target chat.
|
||||
|
||||
When using `--as user`, the reply is sent as the authorized end user and requires the `im:message.send_as_user` and `im:message` scopes.
|
||||
|
||||
## Choose The Right Content Flag
|
||||
|
||||
| Need | Recommended flag | Why |
|
||||
@@ -152,7 +154,7 @@ lark-cli im +messages-reply --message-id om_xxx --markdown $'## Test\n\nhello' -
|
||||
| `--audio <path\|key>` | One content option | Local audio path or `file_key` |
|
||||
| `--reply-in-thread` | No | Reply inside the thread. The reply appears in the target message's thread instead of the main chat stream |
|
||||
| `--idempotency-key <key>` | No | Idempotency key; the same key sends only one reply within 1 hour |
|
||||
| `--as <identity>` | No | Identity type: `bot` only |
|
||||
| `--as <identity>` | No | Identity type: `bot` or `user` (default `bot`) |
|
||||
| `--dry-run` | No | Print the request only, do not execute it |
|
||||
|
||||
> **Mutual exclusivity rule:** `--text`, `--markdown`, `--content`, and `--image`/`--file`/`--video`/`--audio` cannot be used together. Media flags are also mutually exclusive with each other.
|
||||
@@ -209,11 +211,12 @@ The reply appears in the target message's thread and does not show up in the mai
|
||||
- When using `--content`, you are responsible for making the JSON structure match the effective `msg_type`
|
||||
- `--reply-in-thread` adds `reply_in_thread=true` to the API request
|
||||
- `--reply-in-thread` is mainly meaningful in chats that support thread replies
|
||||
- `--image`/`--file`/`--video`/`--audio`/`--video-cover` support local file paths; the shortcut uploads first and then sends the reply
|
||||
- `--image`/`--file`/`--video`/`--audio`/`--video-cover` support local file paths; the shortcut uploads first and then sends the reply; file/image upload is bot-only, so when using `--as user`, the upload step is automatically performed with bot identity, and only the final send uses user identity
|
||||
- If the provided media value starts with `img_` or `file_`, it is treated as an existing key and used directly
|
||||
- `--markdown` always sends `msg_type=post`
|
||||
- If you explicitly set `--msg-type` and it conflicts with the chosen content flag, validation fails
|
||||
- When using `--video`, `--video-cover` is required as the video cover
|
||||
- `--dry-run` uses placeholder image keys for remote Markdown images and placeholder media keys for local uploads
|
||||
- Failures return error codes and messages
|
||||
- `--as bot` uses a tenant access token (TAT), and requires the `im:message:send_as_bot` scope
|
||||
- `--as user` uses a user access token (UAT) and requires the `im:message.send_as_user` and `im:message` scopes; the reply is sent as the authorized end user
|
||||
- `--as bot` uses a tenant access token (TAT), and requires the `im:message:send_as_bot` scope
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
|
||||
|
||||
Send a message to a group chat or a direct message conversation. Only supports bot identity.
|
||||
Send a message to a group chat or a direct message conversation. Supports both user identity (`--as user`) and bot identity (`--as bot`).
|
||||
|
||||
This skill maps to the shortcut: `lark-cli im +messages-send` (internally calls `POST /open-apis/im/v1/messages`).
|
||||
|
||||
@@ -12,12 +12,14 @@ Messages sent by this tool are visible to other people. Before calling it, you *
|
||||
|
||||
1. The recipient (which person or which group)
|
||||
2. The message content
|
||||
3. The sending identity (bot only)
|
||||
3. The sending identity (user or bot)
|
||||
|
||||
**Do not** send messages without explicit user approval.
|
||||
|
||||
When using `--as bot`, the message is sent in the app's name, so make sure the app has already been added to the target chat.
|
||||
|
||||
When using `--as user`, the message is sent as the authorized end user and requires the `im:message.send_as_user` and `im:message` scopes.
|
||||
|
||||
## Choose The Right Content Flag
|
||||
|
||||
| Need | Recommended flag | Why |
|
||||
@@ -158,7 +160,7 @@ lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Test\n\nhello' --dry
|
||||
| `--audio <path\|key>` | One content option | Local audio path or `file_key`. Local paths are uploaded automatically |
|
||||
| `--msg-type <type>` | No | Message type (default `text`). If you use `--text` / `--markdown` / media flags, the effective type is inferred automatically. Explicitly setting a conflicting `--msg-type` fails validation |
|
||||
| `--idempotency-key <key>` | No | Idempotency key; the same key sends only one message within 1 hour |
|
||||
| `--as <identity>` | No | Identity type: `bot` only |
|
||||
| `--as <identity>` | No | Identity type: `bot` or `user` (default `bot`) |
|
||||
| `--dry-run` | No | Print the request only, do not execute it |
|
||||
|
||||
> **Mutual exclusivity rule:** `--text`, `--markdown`, `--content`, and `--image`/`--file`/`--video`/`--audio` cannot be used together. Media flags are also mutually exclusive with each other.
|
||||
@@ -209,12 +211,13 @@ lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Test\n\nhello' --dry
|
||||
- `--chat-id` and `--user-id` are mutually exclusive; you must provide exactly one
|
||||
- `--content` must be valid JSON
|
||||
- When using `--content`, you are responsible for making the JSON structure match the effective `msg_type`
|
||||
- `--image`/`--file`/`--video`/`--audio` support local file paths; the shortcut uploads first and then sends the message
|
||||
- `--image`/`--file`/`--video`/`--audio` support local file paths; the shortcut uploads first and then sends the message; file/image upload is bot-only, so when using `--as user`, the upload step is automatically performed with bot identity, and only the final send uses user identity
|
||||
- If the provided media value starts with `img_` or `file_`, it is treated as an existing key and used directly
|
||||
- `--markdown` always sends `msg_type=post`, even if you do not explicitly set `--msg-type post`
|
||||
- If you explicitly set `--msg-type` and it conflicts with the chosen content flag, validation fails
|
||||
- When using `--video`, `--video-cover` is required as the video cover
|
||||
- `--dry-run` uses placeholder image keys for remote Markdown images and placeholder media keys for local uploads
|
||||
- Failures return an error code and message
|
||||
- `--as user` uses a user access token (UAT) and requires the `im:message.send_as_user` and `im:message` scopes; the message is sent as the authorized end user
|
||||
- `--as bot` uses a tenant access token (TAT) and requires the `im:message:send_as_bot` scope
|
||||
- When sending as a bot, the app must already be in the target group or already have a direct-message relationship with the target user
|
||||
- When sending as a bot, the app must already be in the target group or already have a direct-message relationship with the target user
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user