Compare commits

..

2 Commits

Author SHA1 Message Date
xiaodiyin
896e72d50d fix(install): harden checksum verification and module loading 2026-04-09 23:05:10 +08:00
xiaodiyin
066e7011a1 fix(security): verify release checksum and harden download path 2026-04-09 20:14:54 +08:00
19 changed files with 432 additions and 450 deletions

View File

@@ -45,6 +45,32 @@ jobs:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- name: Fetch checksums.txt from GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release download "${GITHUB_REF_NAME}" \
--repo "${GITHUB_REPOSITORY}" \
--pattern checksums.txt \
--dir .
- name: Verify checksums.txt is present and matches current version
run: |
set -euo pipefail
test -s checksums.txt
VERSION="${GITHUB_REF_NAME#v}"
for plat in \
"linux-amd64.tar.gz" \
"linux-arm64.tar.gz" \
"darwin-amd64.tar.gz" \
"darwin-arm64.tar.gz" \
"windows-amd64.zip" \
"windows-arm64.zip"
do
grep -qE '^[0-9a-fA-F]{64}[[:space:]]+\*?lark-cli-'"${VERSION}"'-'"${plat}"'$' checksums.txt \
|| { echo "checksums.txt missing valid entry for lark-cli-${VERSION}-${plat}"; exit 1; }
done
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -2,40 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.7] - 2026-04-09
### Features
- Auto-grant current user access for bot-created docs, sheets, imports, and uploads (#360)
- **mail**: Add `send_as` alias support, mailbox/sender discovery APIs, and mail rules API
- **vc**: Extract note doc tokens from calendar event relation API (#333)
- **wiki**: Add wiki node create shortcut (#320)
- **sheets**: Add `+write-image` shortcut (#343)
- **docs**: Add media-preview shortcut (#334)
- **docs**: Add support for additional search filters (#353)
### Bug Fixes
- **api**: Support stdin and quoted JSON inputs on Windows (#367)
- **doc**: Post-process `docs +fetch` output to improve round-trip fidelity (#214)
- **run**: Add missing binary check for lark-cli execution (#362)
- **config**: Validate appId and appSecret keychain key consistency (#295)
### Refactor
- Route base import guidance to drive `+import` (#368)
- Migrate mail shortcuts to FileIO (#356)
- Migrate drive/doc/sheets shortcuts to FileIO (#339)
- Migrate base shortcuts to FileIO (#347)
### Documentation
- **lark-doc**: Document advanced boolean and intitle search syntax for AI agents (#210)
### Chore
- Add depguard and forbidigo rules to guide FileIO adoption (#342)
## [v1.0.6] - 2026-04-08
### Features
@@ -256,7 +222,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.7]: https://github.com/larksuite/cli/releases/tag/v1.0.7
[v1.0.6]: https://github.com/larksuite/cli/releases/tag/v1.0.6
[v1.0.5]: https://github.com/larksuite/cli/releases/tag/v1.0.5
[v1.0.4]: https://github.com/larksuite/cli/releases/tag/v1.0.4

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.7",
"version": "1.0.6",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"
@@ -28,6 +28,7 @@
"files": [
"scripts/install.js",
"scripts/run.js",
"checksums.txt",
"CHANGELOG.md"
]
}

View File

@@ -3,8 +3,13 @@
const fs = require("fs");
const path = require("path");
const { execSync } = require("child_process");
const { execFileSync } = require("child_process");
const os = require("os");
const crypto = require("crypto");
class ChecksumError extends Error {}
class NetworkError extends Error {}
class PackageIntegrityError extends Error {}
const VERSION = require("../package.json").version;
const REPO = "larksuite/cli";
@@ -21,78 +26,266 @@ const ARCH_MAP = {
arm64: "arm64",
};
const platform = PLATFORM_MAP[process.platform];
const arch = ARCH_MAP[process.arch];
if (!platform || !arch) {
console.error(
`Unsupported platform: ${process.platform}-${process.arch}`
);
process.exit(1);
}
const isWindows = process.platform === "win32";
const ext = isWindows ? ".zip" : ".tar.gz";
const archiveName = `${NAME}-${VERSION}-${platform}-${arch}${ext}`;
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 ALLOWED_INITIAL_HOSTS = new Set([
"github.com",
"registry.npmmirror.com",
]);
const CURL_CONNECT_TIMEOUT_SEC = 10;
const CURL_MAX_TIME_SEC = 120;
const CURL_MAX_REDIRS = 5;
const DEFAULT_CHECKSUM_PATH = path.join(__dirname, "..", "checksums.txt");
// Defensive: escape single quotes for PowerShell literal-string embedding.
// tmpDir comes from mkdtempSync so is controlled, but this hardens against
// future refactors that route external input into the script.
function escapeSingleQuotes(s) {
return s.replace(/'/g, "''");
}
const binDir = path.join(__dirname, "..", "bin");
const dest = path.join(binDir, NAME + (isWindows ? ".exe" : ""));
fs.mkdirSync(binDir, { recursive: true });
function download(url, destPath) {
// --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"] }
);
// JS-layer pre-check: initial URL must be https and in allowlist.
// Redirect targets are NOT host-checked; we rely on curl's
// --proto-redir =https + --max-redirs + SHA256 verify for safety.
const parsed = new URL(url);
if (parsed.protocol !== "https:") {
throw new NetworkError(`Non-HTTPS URL rejected: ${url}`);
}
if (!ALLOWED_INITIAL_HOSTS.has(parsed.hostname)) {
throw new NetworkError(`Untrusted initial host: ${parsed.hostname}`);
}
const args = [
"--fail", // HTTP 4xx/5xx -> non-zero exit
"--location", // follow redirects
"--proto", "=https", // initial URL: https only
"--proto-redir", "=https", // redirect targets: https only
"--max-redirs", String(CURL_MAX_REDIRS),
"--tlsv1.2", // minimum TLS 1.2
"--connect-timeout", String(CURL_CONNECT_TIMEOUT_SEC),
"--max-time", String(CURL_MAX_TIME_SEC),
"--silent", "--show-error",
"--output", destPath,
];
if (isWindows) {
// Schannel CRL check hard-fails when the CRL server is unreachable;
// this flag was in the original install.js and is preserved to
// avoid regression for users in corporate networks.
args.unshift("--ssl-revoke-best-effort");
}
// URL is always the last positional arg.
args.push(url);
try {
execFileSync("curl", args, {
stdio: ["ignore", "ignore", "pipe"],
});
} catch (err) {
if (err.code === "ENOENT") {
// ENOENT is NOT a NetworkError: another source won't help (curl
// is missing). Throw plain Error so the fallback loop re-raises
// instead of silently trying the next URL.
throw new Error(
"curl is required for installation but was not found in PATH. " +
"Install curl or manually download the binary from " +
`https://github.com/${REPO}/releases/tag/v${VERSION}`
);
}
const stderr = err.stderr ? err.stderr.toString().trim() : "";
const exitCode = err.status != null ? err.status : "unknown";
throw new NetworkError(
`curl exited with code ${exitCode}${stderr ? ": " + stderr : ""}`
);
}
}
function install() {
function downloadWithFallback(urls, destPath) {
const attempts = [];
for (const url of urls) {
try {
download(url, destPath);
return url;
} catch (err) {
if (err instanceof NetworkError) {
attempts.push({ url, error: err.message });
continue;
}
// ChecksumError, plain Error (ENOENT), or any other type:
// re-raise immediately without trying the next source.
throw err;
}
}
const detail = attempts
.map((a) => ` - ${a.url}\n ${a.error}`)
.join("\n");
throw new NetworkError(`All download sources failed:\n${detail}`);
}
function extract(archivePath, tmpDir) {
if (isWindows) {
const script =
`$ErrorActionPreference = 'Stop'\n` +
`Expand-Archive -LiteralPath '${escapeSingleQuotes(archivePath)}' ` +
`-DestinationPath '${escapeSingleQuotes(tmpDir)}' -Force\n`;
const scriptPath = path.join(tmpDir, "extract.ps1");
fs.writeFileSync(scriptPath, script, { encoding: "utf-8" });
execFileSync("powershell", [
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy", "Bypass",
"-File", scriptPath,
], { stdio: "ignore" });
} else {
execFileSync("tar", ["-xzf", archivePath, "-C", tmpDir], {
stdio: "ignore",
});
}
}
function verifyChecksum(filePath, expectedHash) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash("sha256");
const stream = fs.createReadStream(filePath);
stream.on("error", reject);
stream.on("data", (chunk) => hash.update(chunk));
stream.on("end", () => {
const actual = hash.digest("hex");
const expected = expectedHash.toLowerCase();
if (actual !== expected) {
reject(new ChecksumError(
`SHA256 mismatch for ${path.basename(filePath)}\n` +
` expected: ${expected}\n` +
` actual: ${actual}`
));
return;
}
resolve();
});
});
}
function getExpectedChecksum(archiveFilename, checksumPath = DEFAULT_CHECKSUM_PATH) {
if (!fs.existsSync(checksumPath)) {
// Packaging bug, not a tamper signal — routed separately.
throw new PackageIntegrityError("checksums.txt missing from package");
}
const contents = fs.readFileSync(checksumPath, "utf-8");
const lineRegex = /^([0-9a-fA-F]{64})\s+\*?(.+)$/;
for (const rawLine of contents.split("\n")) {
const line = rawLine.trim();
if (line === "" || line.startsWith("#")) continue;
const match = line.match(lineRegex);
if (!match) continue;
const [, hash, filename] = match;
if (filename.trim() === archiveFilename) {
return hash.toLowerCase();
}
}
throw new ChecksumError(`No checksum entry for ${archiveFilename}`);
}
async function install() {
const platform = PLATFORM_MAP[process.platform];
const arch = ARCH_MAP[process.arch];
if (!platform || !arch) {
throw new Error(
`Unsupported platform: ${process.platform}-${process.arch}. ` +
`Download manually from ` +
`https://github.com/${REPO}/releases/tag/v${VERSION}`
);
}
const archiveName = `${NAME}-${VERSION}-${platform}-${arch}${ext}`;
const sources = [
`https://github.com/${REPO}/releases/download/v${VERSION}/${archiveName}`,
`https://registry.npmmirror.com/-/binary/lark-cli/v${VERSION}/${archiveName}`,
];
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lark-cli-"));
const archivePath = path.join(tmpDir, archiveName);
try {
try {
download(GITHUB_URL, archivePath);
} catch (err) {
download(MIRROR_URL, archivePath);
}
// 1. Early fail: if the bundled checksums.txt is broken,
// report now before spending bandwidth.
const expectedHash = getExpectedChecksum(archiveName);
if (isWindows) {
execSync(
`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}'"`,
{ stdio: "ignore" }
);
} else {
execSync(`tar -xzf "${archivePath}" -C "${tmpDir}"`, {
stdio: "ignore",
});
}
// 2. Multi-source download; only NetworkError triggers fallback.
const sourceUrl = downloadWithFallback(sources, archivePath);
// 3. Integrity check outside the fallback loop. Mismatch aborts
// the entire install, does NOT try the next source.
await verifyChecksum(archivePath, expectedHash);
// 4. Extract (safe: bytes match the official release).
extract(archivePath, tmpDir);
// 5. Copy binary into place and chmod.
const binaryName = NAME + (isWindows ? ".exe" : "");
const extractedBinary = path.join(tmpDir, binaryName);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(extractedBinary, dest);
fs.chmodSync(dest, 0o755);
console.log(`${NAME} v${VERSION} installed successfully`);
console.log(
`${NAME} v${VERSION} installed successfully ` +
`(from ${new URL(sourceUrl).hostname})`
);
} finally {
// 6. Always clean up the temp directory.
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
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);
if (require.main === module) {
install().catch((err) => {
if (err instanceof PackageIntegrityError) {
console.error(`\n${NAME} install aborted: the installed package looks broken.\n`);
console.error(err.message);
console.error(
`\nRe-install the package; if the issue persists, please report it:\n` +
` https://github.com/${REPO}/issues\n`
);
} else if (err instanceof ChecksumError) {
console.error(`\n[SECURITY] ${NAME} install aborted due to integrity check failure:\n`);
console.error(err.message);
console.error(
`\nRetry the install; if it persists, report it and download manually:\n` +
` https://github.com/${REPO}/releases/tag/v${VERSION}\n`
);
} else if (err instanceof NetworkError) {
console.error(`\n${NAME} install failed due to network errors:\n`);
console.error(err.message);
console.error(
`\nIf you are behind a firewall or on a restricted network, try configuring a proxy:\n` +
` export https_proxy=http://your-proxy:port\n` +
` npm install -g @larksuite/cli\n`
);
} else {
console.error(`\n${NAME} install failed:\n${err.stack || err.message}`);
}
process.exit(1);
});
}
module.exports = {
verifyChecksum,
getExpectedChecksum,
ChecksumError,
NetworkError,
PackageIntegrityError,
};

103
scripts/install.test.js Normal file
View File

@@ -0,0 +1,103 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const { test } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const crypto = require("crypto");
const {
verifyChecksum,
getExpectedChecksum,
ChecksumError,
PackageIntegrityError,
} = require("./install.js");
function mktmpdir() {
return fs.mkdtempSync(path.join(os.tmpdir(), "install-test-"));
}
test("verifyChecksum: correct hash resolves", async () => {
const dir = mktmpdir();
try {
const filePath = path.join(dir, "data.bin");
const bytes = Buffer.from("hello world");
fs.writeFileSync(filePath, bytes);
const correctHash = crypto.createHash("sha256").update(bytes).digest("hex");
await verifyChecksum(filePath, correctHash);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("verifyChecksum: mismatched hash throws ChecksumError", async () => {
const dir = mktmpdir();
try {
const filePath = path.join(dir, "data.bin");
fs.writeFileSync(filePath, "hello world");
const wrongHash = "0".repeat(64);
await assert.rejects(
() => verifyChecksum(filePath, wrongHash),
(err) => err instanceof ChecksumError,
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("getExpectedChecksum: returns hash for listed archive", () => {
const dir = mktmpdir();
try {
const checksumsPath = path.join(dir, "checksums.txt");
const knownHash = "a".repeat(64);
fs.writeFileSync(
checksumsPath,
`${knownHash} lark-cli-1.0.0-linux-amd64.tar.gz\n`
);
const result = getExpectedChecksum(
"lark-cli-1.0.0-linux-amd64.tar.gz",
checksumsPath,
);
assert.strictEqual(result, knownHash);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("getExpectedChecksum: throws PackageIntegrityError (not ChecksumError) when checksums.txt file is absent", () => {
const dir = mktmpdir();
try {
const missingPath = path.join(dir, "does-not-exist.txt");
assert.throws(
() => getExpectedChecksum("lark-cli-1.0.0-linux-amd64.tar.gz", missingPath),
(err) =>
err instanceof PackageIntegrityError &&
!(err instanceof ChecksumError),
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("getExpectedChecksum: throws ChecksumError when entry missing", () => {
const dir = mktmpdir();
try {
const checksumsPath = path.join(dir, "checksums.txt");
fs.writeFileSync(
checksumsPath,
`${"a".repeat(64)} some-other-archive.tar.gz\n`
);
assert.throws(
() => getExpectedChecksum("nonexistent-archive.tar.gz", checksumsPath),
(err) => err instanceof ChecksumError,
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

View File

@@ -22,10 +22,6 @@ var BaseFieldCreate = common.Shortcut{
{Name: "json", Desc: "field property JSON object", Required: true},
{Name: "i-have-read-guide", Type: "bool", Desc: "set only after you have read the formula/lookup guide for those field types", Hidden: true},
},
Tips: []string{
`Example: --json '{"name":"Status","type":"text"}'`,
"Agent hint: use the lark-base skill's field-create guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateFieldCreate(runtime)
},

View File

@@ -23,10 +23,6 @@ var BaseFieldUpdate = common.Shortcut{
{Name: "json", Desc: "field property JSON object", Required: true},
{Name: "i-have-read-guide", Type: "bool", Desc: "acknowledge reading formula/lookup guide before creating or updating those field types", Hidden: true},
},
Tips: []string{
`Example: --json '{"name":"Status","type":"text"}'`,
"Agent hint: use the lark-base skill's field-update guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateFieldUpdate(runtime)
},

View File

@@ -22,10 +22,6 @@ var BaseRecordUpsert = common.Shortcut{
recordRefFlag(false),
{Name: "json", Desc: "record JSON object", Required: true},
},
Tips: []string{
`Example: --json '{"Name":"Alice"}'`,
"Agent hint: use the lark-base skill's record-upsert guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordJSON(runtime)
},

View File

@@ -21,10 +21,6 @@ var BaseViewCreate = common.Shortcut{
tableRefFlag(true),
{Name: "json", Desc: "view JSON object/array", Required: true},
},
Tips: []string{
`Example: --json '{"name":"Main","type":"grid"}'`,
"Agent hint: use the lark-base skill's view-create guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewCreate(runtime)
},

View File

@@ -22,10 +22,6 @@ var BaseViewSetCard = common.Shortcut{
viewRefFlag(true),
{Name: "json", Desc: "card JSON object", Required: true},
},
Tips: []string{
`Example: --json '{"cover_field":"fldCover"}'`,
"Agent hint: use the lark-base skill's view-set-card guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONObject(runtime)
},

View File

@@ -22,10 +22,6 @@ var BaseViewSetFilter = common.Shortcut{
viewRefFlag(true),
{Name: "json", Desc: "filter JSON object", Required: true},
},
Tips: []string{
`Example: --json '{"logic":"and","conditions":[["fldStatus","==","Todo"]]}'`,
"Agent hint: use the lark-base skill's view-set-filter guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONObject(runtime)
},

View File

@@ -20,11 +20,7 @@ var BaseViewSetGroup = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
viewRefFlag(true),
{Name: "json", Desc: "group JSON object", Required: true},
},
Tips: []string{
`Example: --json '{"group_config":[{"field":"fldStatus","desc":false}]}'`,
"Agent hint: use the lark-base skill's view-set-group guide for usage and limits.",
{Name: "json", Desc: "group JSON object/array", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONValue(runtime)

View File

@@ -22,10 +22,6 @@ var BaseViewSetSort = common.Shortcut{
viewRefFlag(true),
{Name: "json", Desc: "sort JSON object/array", Required: true},
},
Tips: []string{
`Example: --json '[{"field":"fldPriority","desc":true}]'`,
"Agent hint: use the lark-base skill's view-set-sort guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONValue(runtime)
},

View File

@@ -22,10 +22,6 @@ var BaseViewSetTimebar = common.Shortcut{
viewRefFlag(true),
{Name: "json", Desc: "timebar JSON object", Required: true},
},
Tips: []string{
`Example: --json '{"start_time":"fldStart","end_time":"fldEnd","title":"fldTitle"}'`,
"Agent hint: use the lark-base skill's view-set-timebar guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONObject(runtime)
},

View File

@@ -7,6 +7,8 @@ import (
"context"
"fmt"
"net/http"
"path/filepath"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@@ -16,6 +18,17 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
var mimeToExt = map[string]string{
"image/png": ".png",
"image/jpeg": ".jpg",
"image/gif": ".gif",
"image/webp": ".webp",
"image/svg+xml": ".svg",
"application/pdf": ".pdf",
"video/mp4": ".mp4",
"text/plain": ".txt",
}
var DocMediaDownload = common.Shortcut{
Service: "docs",
Command: "+media-download",
@@ -77,11 +90,19 @@ var DocMediaDownload = common.Shortcut{
}
defer resp.Body.Close()
fallbackExt := ""
if mediaType == "whiteboard" {
fallbackExt = ".png"
// Auto-detect extension from Content-Type
finalPath := outputPath
currentExt := filepath.Ext(outputPath)
if currentExt == "" {
contentType := resp.Header.Get("Content-Type")
mimeType := strings.Split(contentType, ";")[0]
mimeType = strings.TrimSpace(mimeType)
if ext, ok := mimeToExt[mimeType]; ok {
finalPath = outputPath + ext
} else if mediaType == "whiteboard" {
finalPath = outputPath + ".png"
}
}
finalPath, _ := autoAppendDocMediaExtension(outputPath, resp.Header, fallbackExt)
// Validate final path after extension append
if finalPath != outputPath {

View File

@@ -1,105 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"mime"
"net/http"
"path/filepath"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
type docMediaExtensionResolution struct {
Ext string
Source string
Detail string
}
var docMediaMimeToExt = map[string]string{
"application/msword": ".doc",
"application/pdf": ".pdf",
"application/vnd.ms-excel": ".xls",
"application/vnd.ms-powerpoint": ".ppt",
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
"application/xml": ".xml",
"application/zip": ".zip",
"image/bmp": ".bmp",
"image/gif": ".gif",
"image/jpeg": ".jpg",
"image/png": ".png",
"image/svg+xml": ".svg",
"image/webp": ".webp",
"text/csv": ".csv",
"text/html": ".html",
"text/plain": ".txt",
"text/xml": ".xml",
"video/mp4": ".mp4",
}
func autoAppendDocMediaExtension(outputPath string, header http.Header, fallbackExt string) (string, *docMediaExtensionResolution) {
if docMediaHasExplicitExtension(outputPath) {
return outputPath, nil
}
normalizedPath := outputPath
if filepath.Ext(outputPath) == "." {
normalizedPath = strings.TrimSuffix(outputPath, ".")
}
if resolution := docMediaExtensionByContentType(header.Get("Content-Type")); resolution != nil {
return normalizedPath + resolution.Ext, resolution
}
if resolution := docMediaExtensionByContentDisposition(header); resolution != nil {
return normalizedPath + resolution.Ext, resolution
}
if fallbackExt != "" {
return normalizedPath + fallbackExt, &docMediaExtensionResolution{
Ext: fallbackExt,
Source: "fallback",
Detail: "default fallback",
}
}
return outputPath, nil
}
func docMediaHasExplicitExtension(path string) bool {
ext := filepath.Ext(path)
return ext != "" && ext != "."
}
func docMediaExtensionByContentType(contentType string) *docMediaExtensionResolution {
if contentType == "" {
return nil
}
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil {
mediaType = strings.TrimSpace(strings.Split(contentType, ";")[0])
}
if ext, ok := docMediaMimeToExt[strings.ToLower(mediaType)]; ok {
return &docMediaExtensionResolution{
Ext: ext,
Source: "Content-Type",
Detail: contentType,
}
}
return nil
}
func docMediaExtensionByContentDisposition(header http.Header) *docMediaExtensionResolution {
filename := strings.TrimSpace(larkcore.FileNameByHeader(header))
if filename == "" {
return nil
}
ext := filepath.Ext(filename)
if ext == "" || ext == "." {
return nil
}
return &docMediaExtensionResolution{
Ext: ext,
Source: "Content-Disposition",
Detail: filename,
}
}

View File

@@ -7,6 +7,8 @@ import (
"context"
"fmt"
"net/http"
"path/filepath"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@@ -16,6 +18,17 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
var previewMimeToExt = map[string]string{
"image/png": ".png",
"image/jpeg": ".jpg",
"image/gif": ".gif",
"image/webp": ".webp",
"image/svg+xml": ".svg",
"application/pdf": ".pdf",
"video/mp4": ".mp4",
"text/plain": ".txt",
}
const PreviewType_SOURCE_FILE = "16"
var DocMediaPreview = common.Shortcut{
@@ -69,7 +82,16 @@ var DocMediaPreview = common.Shortcut{
}
defer resp.Body.Close()
finalPath, _ := autoAppendDocMediaExtension(outputPath, resp.Header, "")
finalPath := outputPath
currentExt := filepath.Ext(outputPath)
if currentExt == "" {
contentType := resp.Header.Get("Content-Type")
mimeType := strings.Split(contentType, ";")[0]
mimeType = strings.TrimSpace(mimeType)
if ext, ok := previewMimeToExt[mimeType]; ok {
finalPath = outputPath + ext
}
}
// Validate final path after extension append
if finalPath != outputPath {

View File

@@ -18,7 +18,6 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -286,77 +285,6 @@ func TestDocMediaDownloadRejectsHTTPErrorBeforeWrite(t *testing.T) {
}
}
func TestDocMediaDownloadAppendsExtensionFromContentDispositionFilename(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-download-disposition-app"))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/tok_123/download",
Status: 200,
Body: []byte("a,b,c\n1,2,3\n"),
Headers: http.Header{
"Content-Type": []string{"application/octet-stream"},
"Content-Disposition": []string{`attachment; filename="drive_registry_config_addition.csv"`},
},
})
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
err := mountAndRunDocs(t, DocMediaDownload, []string{
"+media-download",
"--token", "tok_123",
"--output", "download",
"--as", "bot",
}, f, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := decodeDocCommandOutput(t, stdout)
wantPath := mustDocSafeOutputPath(t, "download.csv")
if got.Data.SavedPath != wantPath {
t.Fatalf("saved_path = %q, want %q", got.Data.SavedPath, wantPath)
}
if _, err := os.Stat(wantPath); err != nil {
t.Fatalf("expected downloaded file at %q: %v", wantPath, err)
}
}
func TestDocMediaDownloadAppendsExtensionForTrailingDotOutput(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-download-trailing-dot-app"))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/tok_123/download",
Status: 200,
Body: []byte("a,b,c\n1,2,3\n"),
Headers: http.Header{
"Content-Type": []string{"text/csv; charset=utf-8"},
},
})
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
err := mountAndRunDocs(t, DocMediaDownload, []string{
"+media-download",
"--token", "tok_123",
"--output", "typed.",
"--as", "bot",
}, f, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := decodeDocCommandOutput(t, stdout)
wantPath := mustDocSafeOutputPath(t, "typed.csv")
if got.Data.SavedPath != wantPath {
t.Fatalf("saved_path = %q, want %q", got.Data.SavedPath, wantPath)
}
if _, err := os.Stat(wantPath); err != nil {
t.Fatalf("expected downloaded file at %q: %v", wantPath, err)
}
}
func TestDocMediaPreviewDryRunUsesMediaEndpoint(t *testing.T) {
cmd := &cobra.Command{Use: "docs +media-preview"}
cmd.Flags().String("token", "", "")
@@ -443,113 +371,6 @@ func TestDocMediaPreviewRejectsHTTPErrorBeforeWrite(t *testing.T) {
}
}
func TestDocMediaPreviewAppendsExtensionFromRFC5987Filename(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-preview-disposition-app"))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/tok_123/preview_download?preview_type=" + PreviewType_SOURCE_FILE,
Status: 200,
Body: []byte("a,b,c\n1,2,3\n"),
Headers: http.Header{
"Content-Type": []string{"application/octet-stream"},
"Content-Disposition": []string{`attachment; filename*=UTF-8''drive_registry_config_addition.csv`},
},
})
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
err := mountAndRunDocs(t, DocMediaPreview, []string{
"+media-preview",
"--token", "tok_123",
"--output", "preview",
"--as", "bot",
}, f, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := decodeDocCommandOutput(t, stdout)
wantPath := mustDocSafeOutputPath(t, "preview.csv")
if got.Data.SavedPath != wantPath {
t.Fatalf("saved_path = %q, want %q", got.Data.SavedPath, wantPath)
}
if _, err := os.Stat(wantPath); err != nil {
t.Fatalf("expected preview file at %q: %v", wantPath, err)
}
}
func TestDocMediaPreviewAppendsExtensionForTrailingDotOutput(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-preview-trailing-dot-app"))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/tok_123/preview_download?preview_type=" + PreviewType_SOURCE_FILE,
Status: 200,
Body: []byte("a,b,c\n1,2,3\n"),
Headers: http.Header{
"Content-Disposition": []string{`attachment; filename*=UTF-8''drive_registry_config_addition.csv`},
"Content-Type": []string{"application/octet-stream"},
},
})
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
err := mountAndRunDocs(t, DocMediaPreview, []string{
"+media-preview",
"--token", "tok_123",
"--output", "preview.",
"--as", "bot",
}, f, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := decodeDocCommandOutput(t, stdout)
wantPath := mustDocSafeOutputPath(t, "preview.csv")
if got.Data.SavedPath != wantPath {
t.Fatalf("saved_path = %q, want %q", got.Data.SavedPath, wantPath)
}
if _, err := os.Stat(wantPath); err != nil {
t.Fatalf("expected preview file at %q: %v", wantPath, err)
}
}
func TestDocMediaDownloadAppendsExtensionFromContentTypeMapping(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-download-content-type-app"))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/tok_123/download",
Status: 200,
Body: []byte("a,b,c\n1,2,3\n"),
Headers: http.Header{
"Content-Type": []string{"text/csv; charset=utf-8"},
},
})
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
err := mountAndRunDocs(t, DocMediaDownload, []string{
"+media-download",
"--token", "tok_123",
"--output", "typed",
"--as", "bot",
}, f, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := decodeDocCommandOutput(t, stdout)
wantPath := mustDocSafeOutputPath(t, "typed.csv")
if got.Data.SavedPath != wantPath {
t.Fatalf("saved_path = %q, want %q", got.Data.SavedPath, wantPath)
}
if _, err := os.Stat(wantPath); err != nil {
t.Fatalf("expected downloaded file at %q: %v", wantPath, err)
}
}
type docDryRunOutput struct {
Description string `json:"description"`
API []struct {
@@ -560,15 +381,6 @@ type docDryRunOutput struct {
} `json:"api"`
}
type docCommandOutput struct {
OK bool `json:"ok"`
Data struct {
SavedPath string `json:"saved_path"`
SizeBytes int64 `json:"size_bytes"`
ContentType string `json:"content_type"`
} `json:"data"`
}
func writeSizedDocTestFile(t *testing.T, name string, size int64) {
t.Helper()
@@ -598,23 +410,3 @@ func decodeDocDryRun(t *testing.T, dryAPI *common.DryRunAPI) docDryRunOutput {
}
return dry
}
func decodeDocCommandOutput(t *testing.T, stdout *bytes.Buffer) docCommandOutput {
t.Helper()
var out docCommandOutput
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
t.Fatalf("decode command output: %v; output=%s", err, stdout.String())
}
return out
}
func mustDocSafeOutputPath(t *testing.T, output string) string {
t.Helper()
path, err := validate.SafeOutputPath(output)
if err != nil {
t.Fatalf("SafeOutputPath(%q) error: %v", output, err)
}
return path
}

View File

@@ -11,17 +11,15 @@ lark-cli base +view-set-group \
--base-token app_xxx \
--table-id tbl_xxx \
--view-id viw_xxx \
--json '{"group_config":[{"field":"fldStatus","desc":false}]}'
--json [{"field":"fld_status","desc":false}]
```
## JSON 结构
```json
{
"group_config": [
{ "field": "fldStatus", "desc": false }
]
}
[
{ "field": "fld_status", "desc": false }
]
```
## 参数
@@ -31,7 +29,7 @@ lark-cli base +view-set-group \
| `--base-token <token>` | 是 | Base Token |
| `--table-id <id_or_name>` | 是 | 表 ID 或表名 |
| `--view-id <id_or_name>` | 是 | 视图 ID 或视图名 |
| `--json <body>` | 是 | JSON 对象 |
| `--json <body>` | 是 | JSON 对象或数组 |
## API 入参详情
@@ -51,12 +49,14 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/views/:view_id/group
- 每项:
- `field`:字段 id 或字段名,长度 `1..100`
- `desc`:可选,默认 `false`
- `--json` 既可传对象 `{"group_config":[...]}`,也可直接传数组 `[...]`
- 直接传数组时CLI 会自动包装成 `group_config`
## JSON Schema原文
```json
{"type":"object","properties":{"group_config":{"type":"array","items":{"type":"object","properties":{"field":{"type":"string","minLength":1,"maxLength":100,"description":"Field id or name"},"desc":{"type":"boolean","default":false,"description":"define how to sort group headers"}},"required":["field"],"additionalProperties":false},"minItems":0,"maxItems":3}},"required":["group_config"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}
{"type":"array","items":{"type":"object","properties":{"field":{"type":"string","minLength":1,"maxLength":100,"description":"Field id or name"},"desc":{"type":"boolean","default":false,"description":"define how to sort group headers"}},"required":["field"],"additionalProperties":false},"minItems":0,"maxItems":3,"$schema":"http://json-schema.org/draft-07/schema#"}
```