Compare commits

...

3 Commits

Author SHA1 Message Date
liangshuo-1
003d0f42f8 chore: release v1.0.81 (#2136) 2026-07-31 18:47:19 +08:00
wangweiming-01
7946e5c81d feat: support source file preview artifacts (#2085) 2026-07-31 17:52:31 +08:00
zhouyue-bytedance
5cf09ecfda docs(base): clarify form and file operation routing (#2110)
* docs(base): clarify form and file operation routing

* docs: clarify complete base role table rules

* docs: clarify base advanced permission status

* docs: clarify base form field lifecycle

* docs: guide base form question creation

* fix(base): address form dry-run review findings

* docs(base): add complete editable role example

* fix(base): validate form question create inputs
2026-07-31 15:23:03 +08:00
32 changed files with 1165 additions and 141 deletions

View File

@@ -2,6 +2,35 @@
All notable changes to this project will be documented in this file.
## [v1.0.81] - 2026-07-31
### Features
- support visible_rule for form questions (#1891)
- **contact**: add bot search shortcut (#2083)
- add SXSD schema validation to Slides lint (#2103)
- **drive**: add comment-operation shortcuts (#1898)
- **drive**: extend permission shortcuts for Miaoda (#2070)
- **apps**: add cache debug commands (+cache-get/-delete/-clear) (#1896)
- support source file preview artifacts (#2085)
### Bug Fixes
- **contact**: stop bot match segments carrying tags or empty entries (#2115)
- **base**: resolve Base URL block types accurately (#2099)
- **drive**: use title for default download filename (#2089)
- drop stale target version from root upgrade prompt (#2100)
### Documentation
- **calendar**: warn against container-default timezone in time conversion (#2104)
- **calendar**: confirm scope before editing recurring events (#2119)
- **base**: clarify form and file operation routing (#2110)
### Misc
- add protected public domain allowlists (#2111)
## [v1.0.80] - 2026-07-29
### Features
@@ -1722,6 +1751,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.81]: https://github.com/larksuite/cli/releases/tag/v1.0.81
[v1.0.80]: https://github.com/larksuite/cli/releases/tag/v1.0.80
[v1.0.79]: https://github.com/larksuite/cli/releases/tag/v1.0.79
[v1.0.78]: https://github.com/larksuite/cli/releases/tag/v1.0.78

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@larksuite/cli",
"version": "1.0.80",
"version": "1.0.81",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@larksuite/cli",
"version": "1.0.80",
"version": "1.0.81",
"cpu": [
"x64",
"arm64",

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.80",
"version": "1.0.81",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
@@ -27,19 +28,23 @@ var BaseFormQuestionsCreate = common.Shortcut{
{Name: "form-id", Desc: "form ID", Required: true},
{Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}),"visible_rule"(display condition; same shape as view filter {"logic":"and","conditions":[["前序题目","==","是"]]}, field references another question's title/id, empty/absent = always shown). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
},
Tips: []string{
"If the form may already contain questions and has not been checked, run +form-questions-list for the same --base-token, --table-id, and --form-id. A verified empty form can create directly.",
"Each new question creates a field in the form's table; question IDs are field IDs.",
"Unless the user explicitly requests a separate same-title question, update an existing title with +form-questions-update instead of creating a duplicate.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := parseFormQuestionsCreate(runtime.Str("questions"))
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
api := common.NewDryRunAPI().
questions, _ := parseFormQuestionsCreate(runtime.Str("questions"))
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
Set("base_token", runtime.Str("base-token")).
Set("table_id", runtime.Str("table-id")).
Set("form_id", runtime.Str("form-id"))
// Transcribe the questions body verbatim so the preview shows exactly
// what would be sent (including optional fields like visible_rule).
var questions []interface{}
if err := json.Unmarshal([]byte(runtime.Str("questions")), &questions); err == nil {
api.Body(map[string]interface{}{"questions": questions})
}
return api
Set("form_id", runtime.Str("form-id")).
Body(map[string]interface{}{"questions": questions})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
baseToken := runtime.Str("base-token")
@@ -47,9 +52,9 @@ var BaseFormQuestionsCreate = common.Shortcut{
formId := runtime.Str("form-id")
questionsJSON := runtime.Str("questions")
var questions []interface{}
if err := json.Unmarshal([]byte(questionsJSON), &questions); err != nil {
return baseValidationErrorf("--questions must be a valid JSON array: %s", err)
questions, err := parseFormQuestionsCreate(questionsJSON)
if err != nil {
return err
}
data, err := baseV3Call(runtime, "POST",
@@ -78,3 +83,31 @@ var BaseFormQuestionsCreate = common.Shortcut{
return nil
},
}
func parseFormQuestionsCreate(raw string) ([]interface{}, error) {
var questions []interface{}
if err := json.Unmarshal([]byte(raw), &questions); err != nil {
return nil, baseValidationErrorf("--questions must be a valid JSON array: %s", err)
}
if questions == nil {
return nil, baseValidationErrorf("--questions must be a non-null JSON array")
}
if len(questions) > 10 {
return nil, baseValidationErrorf("--questions must contain at most 10 items")
}
for i, question := range questions {
item, ok := question.(map[string]interface{})
if !ok {
return nil, baseValidationErrorf("--questions item %d must be an object", i+1)
}
title, ok := item["title"].(string)
if !ok || strings.TrimSpace(title) == "" {
return nil, baseValidationErrorf("--questions item %d must include a non-empty string \"title\"", i+1)
}
questionType, ok := item["type"].(string)
if !ok || strings.TrimSpace(questionType) == "" {
return nil, baseValidationErrorf("--questions item %d must include a non-empty string \"type\"", i+1)
}
}
return questions, nil
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"strings"
"testing"
)
func TestBaseFormQuestionsCreateTipsRequireExistingQuestionCheck(t *testing.T) {
tips := strings.Join(BaseFormQuestionsCreate.Tips, "\n")
for _, want := range []string{
"+form-questions-list",
"verified empty form can create directly",
"question IDs are field IDs",
"explicitly requests a separate same-title question",
"+form-questions-update",
} {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
}

View File

@@ -202,7 +202,7 @@ var DriveDownload = common.Shortcut{
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
return wrapDriveNetworkErr(err, "download failed: %s", err)
return withDriveDownloadForbiddenPreviewHint(wrapDriveNetworkErr(err, "download failed: %s", err), fileToken)
}
defer resp.Body.Close()

View File

@@ -5,6 +5,8 @@ package drive
import (
"errors"
"fmt"
"net/http"
"strings"
"github.com/larksuite/cli/errs"
@@ -21,6 +23,30 @@ func wrapDriveNetworkErr(err error, format string, args ...any) error {
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
}
// withDriveDownloadForbiddenPreviewHint keeps the HTTP 403 network error from
// +download intact while giving callers a preview-based path to view content.
func withDriveDownloadForbiddenPreviewHint(err error, _ string) error {
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryNetwork || problem.Code != http.StatusForbidden {
return err
}
if strings.Contains(problem.Hint, "drive +preview") {
return err
}
hint := driveDownloadForbiddenPreviewHint()
if strings.TrimSpace(problem.Hint) == "" {
problem.Hint = hint
return err
}
problem.Hint = strings.TrimSpace(problem.Hint) + " " + hint
return err
}
func driveDownloadForbiddenPreviewHint() string {
const tokenArg = "<FILE_TOKEN>"
return fmt.Sprintf("Direct Drive download returned HTTP 403. To view file content through preview artifacts, try `lark-cli drive +preview --file-token %s --type source_file --output <path>`; for PDF/text/image preview choices, run `lark-cli drive +preview --file-token %s --list-only`.", tokenArg, tokenArg)
}
// driveInputStatError maps a FileIO.Stat/Open error for input file validation
// to a typed validation error:
// - Path validation failures → "unsafe file path: ..."

View File

@@ -1580,6 +1580,84 @@ func TestDriveDownloadAllowsOverwriteFlag(t *testing.T) {
}
}
func TestDriveDownloadHTTP403SuggestsPreview(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/file_403/download",
Status: http.StatusForbidden,
RawBody: []byte("permission denied"),
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveDownload, []string{
"+download",
"--file-token", "file_403",
"--output", "blocked.md",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected HTTP 403 error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Category != errs.CategoryNetwork {
t.Fatalf("category=%q, want network", problem.Category)
}
if problem.Code != http.StatusForbidden {
t.Fatalf("code=%d, want %d", problem.Code, http.StatusForbidden)
}
if !strings.Contains(problem.Hint, "drive +preview") {
t.Fatalf("hint=%q, want preview guidance", problem.Hint)
}
if strings.Contains(problem.Hint, "file_403") {
t.Fatalf("hint=%q, want placeholder file token", problem.Hint)
}
if !strings.Contains(problem.Hint, "--file-token <FILE_TOKEN>") {
t.Fatalf("hint=%q, want file token placeholder", problem.Hint)
}
if !strings.Contains(problem.Hint, "--type source_file") || !strings.Contains(problem.Hint, "--output <path>") {
t.Fatalf("hint=%q, want source_file output command", problem.Hint)
}
}
func TestDriveDownloadHTTP404DoesNotSuggestPreview(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/file_missing/download",
Status: http.StatusNotFound,
RawBody: []byte("not found"),
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveDownload, []string{
"+download",
"--file-token", "file_missing",
"--output", "missing.md",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected HTTP 404 error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Code != http.StatusNotFound {
t.Fatalf("code=%d, want %d", problem.Code, http.StatusNotFound)
}
if strings.Contains(problem.Hint, "drive +preview") {
t.Fatalf("hint=%q, want no preview guidance for non-403", problem.Hint)
}
}
func TestDriveDownloadDefaultOutputPathSanitizesSlashOnlyNames(t *testing.T) {
header := http.Header{
"Content-Disposition": []string{`attachment; filename="////"`},

View File

@@ -16,13 +16,13 @@ import (
var DrivePreview = common.Shortcut{
Service: "drive",
Command: "+preview",
Description: "List or download available preview artifacts for a Drive file",
Description: "View or download Drive file content, or list and fetch available preview artifacts",
Risk: "read",
Scopes: []string{"drive:file:download"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file-token", Desc: "Drive file token", Required: true},
{Name: "type", Desc: "preview type to download: pdf | html | text | image | source"},
{Name: "type", Desc: "preview type to download: pdf | html | text | image | source_file"},
{Name: "version", Desc: "optional file version"},
{Name: "list-only", Type: "bool", Desc: "list preview candidates without downloading"},
{Name: "output", Desc: "local output path for downloaded preview"},
@@ -40,6 +40,25 @@ var DrivePreview = common.Shortcut{
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
fileToken := runtime.Str("file-token")
version := strings.TrimSpace(runtime.Str("version"))
requestedType := strings.TrimSpace(runtime.Str("type"))
if requestedType == "source_file" {
downloadParams := map[string]interface{}{
"preview_type": drivePreviewTypeSourceFile,
}
if version != "" {
downloadParams["version"] = version
}
return common.NewDryRunAPI().
GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("Download the source file artifact").
Params(downloadParams).
Set("file_token", fileToken).
Set("mode", "download").
Set("requested_type", requestedType).
Set("selected_type", "source_file").
Set("selected_type_code", drivePreviewTypeSourceFile).
Set("output", runtime.Str("output"))
}
body := map[string]interface{}{}
if version != "" {
body["version"] = version
@@ -67,7 +86,7 @@ var DrivePreview = common.Shortcut{
Desc("[2] Download the requested preview after selecting a matching candidate from preview_result").
Params(downloadParams).
Set("mode", "download").
Set("requested_type", runtime.Str("type")).
Set("requested_type", requestedType).
Set("output", runtime.Str("output"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -82,9 +101,25 @@ var DrivePreview = common.Shortcut{
body["version"] = version
}
if requestedType == "source_file" {
fmt.Fprintf(runtime.IO().ErrOut, "Downloading source file artifact: %s\n", common.MaskToken(fileToken))
result, err := downloadDrivePreviewArtifact(ctx, runtime, fileToken, drivePreviewTypeSourceFile, version, outputPath, ifExists, drivePreviewFallbackExt("source_file"))
if err != nil {
return err
}
result["mode"] = "download"
result["file_token"] = fileToken
result["selected_type"] = "source_file"
runtime.Out(result, nil)
return nil
}
fmt.Fprintf(runtime.IO().ErrOut, "Fetching preview candidates: %s\n", common.MaskToken(fileToken))
data, candidates, err := fetchDrivePreviewCandidates(runtime, fileToken, body)
if err != nil {
if runtime.Bool("list-only") {
return withDrivePreviewSourceFileHint(err)
}
return err
}
if runtime.Bool("list-only") {

View File

@@ -27,6 +27,8 @@ const (
drivePreviewIfExistsError = "error"
drivePreviewIfExistsOverwrite = "overwrite"
drivePreviewIfExistsRename = "rename"
drivePreviewTypeSourceFile = "16"
drivePreviewSourceFileHint = "Preview candidates are unavailable for this file. To fetch the source file artifact, rerun with --type source_file --output <path>."
)
type drivePreviewCandidate struct {
@@ -88,7 +90,9 @@ var drivePreviewMimeToExt = map[string]string{
"image/webp": ".webp",
"text/csv": ".csv",
"text/html": ".html",
"text/markdown": ".md",
"text/plain": ".txt",
"text/x-markdown": ".md",
"text/xml": ".xml",
"video/mp4": ".mp4",
"application/octet-stream": "",
@@ -464,7 +468,7 @@ func downloadDrivePreviewArtifactWithParams(ctx context.Context, runtime *common
}
defer resp.Body.Close()
finalPath, _, err := resolveDrivePreviewOutputPath(runtime, outputPath, resp.Header, fallbackExt, ifExists)
finalPath, _, err := resolveDrivePreviewOutputPath(runtime, outputPath, resp.Header, fallbackExt, ifExists, fileToken)
if err != nil {
return nil, err
}
@@ -492,8 +496,8 @@ func downloadDrivePreviewArtifactWithParams(ctx context.Context, runtime *common
// resolveDrivePreviewOutputPath finalizes the save path, applying extension
// inference and the selected collision policy.
func resolveDrivePreviewOutputPath(runtime *common.RuntimeContext, outputPath string, header http.Header, fallbackExt, ifExists string) (string, *driveExtensionResolution, error) {
finalPath, resolution := autoAppendDrivePreviewExtension(outputPath, header, fallbackExt)
func resolveDrivePreviewOutputPath(runtime *common.RuntimeContext, outputPath string, header http.Header, fallbackExt, ifExists, fallbackName string) (string, *driveExtensionResolution, error) {
finalPath, resolution := resolveDrivePreviewOutputPathName(runtime, outputPath, header, fallbackExt, fallbackName)
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output")
}
@@ -522,6 +526,32 @@ func resolveDrivePreviewOutputPath(runtime *common.RuntimeContext, outputPath st
}
}
func resolveDrivePreviewOutputPathName(runtime *common.RuntimeContext, outputPath string, header http.Header, fallbackExt, fallbackName string) (string, *driveExtensionResolution) {
if drivePreviewOutputIsDirectory(runtime, outputPath) {
fileName, resolution := drivePreviewDefaultFileName(header, fallbackExt, fallbackName)
return filepath.Join(outputPath, fileName), resolution
}
return autoAppendDrivePreviewExtension(outputPath, header, fallbackExt)
}
func drivePreviewOutputIsDirectory(runtime *common.RuntimeContext, outputPath string) bool {
if strings.HasSuffix(outputPath, "/") || strings.HasSuffix(outputPath, "\\") {
return true
}
info, err := runtime.FileIO().Stat(outputPath)
return err == nil && info.IsDir()
}
func drivePreviewDefaultFileName(header http.Header, fallbackExt, fallbackName string) (string, *driveExtensionResolution) {
name := driveDownloadNormalizeFileName(larkcore.FileNameByHeader(header))
if name == "" {
name = driveDownloadNormalizeFileName(fallbackName)
}
name = sanitizeExportFileName(name, "preview")
name, resolution := autoAppendDrivePreviewExtension(name, header, fallbackExt)
return name, resolution
}
// nextAvailableDrivePreviewPath finds the first unused "name (n)" variant for a
// target output path.
func nextAvailableDrivePreviewPath(fio fileio.FileIO, path string) (string, error) {
@@ -556,6 +586,15 @@ func autoAppendDrivePreviewExtension(outputPath string, header http.Header, fall
if filepath.Ext(outputPath) == "." {
normalizedPath = strings.TrimSuffix(outputPath, ".")
}
if fallbackExt == "" {
if resolution := drivePreviewExtensionByContentDisposition(header); resolution != nil {
return normalizedPath + resolution.Ext, resolution
}
if resolution := drivePreviewExtensionByContentType(header.Get("Content-Type")); resolution != nil {
return normalizedPath + resolution.Ext, resolution
}
return normalizedPath, nil
}
if resolution := drivePreviewExtensionByContentType(header.Get("Content-Type")); resolution != nil {
return normalizedPath + resolution.Ext, resolution
}
@@ -804,6 +843,36 @@ func wrapDrivePreviewNotReady(fileToken, requested string, candidate drivePrevie
return errs.NewValidationError(errs.SubtypeFailedPrecondition, reason).WithHint(hint).WithParam("--type")
}
// withDrivePreviewSourceFileHint adds source_file guidance to preview candidate
// API failures without changing their classification or server diagnostics.
func withDrivePreviewSourceFileHint(err error) error {
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryAPI {
return err
}
if problem.Retryable || problem.Subtype == errs.SubtypeRateLimit {
return err
}
if strings.Contains(problem.Hint, "--type source_file") {
return err
}
if !isDrivePreviewCandidatesUnavailableProblem(problem) {
return err
}
if strings.TrimSpace(problem.Hint) == "" {
problem.Hint = drivePreviewSourceFileHint
return err
}
problem.Hint = strings.TrimSpace(problem.Hint) + " " + drivePreviewSourceFileHint
return err
}
func isDrivePreviewCandidatesUnavailableProblem(problem *errs.Problem) bool {
return problem != nil &&
problem.Code == 1 &&
strings.Contains(problem.Message, "mGetFilePreviewCore failed")
}
// wrapDriveCoverUnavailable builds a validation error for an unknown cover
// spec.
func wrapDriveCoverUnavailable(requested string) error {

View File

@@ -147,6 +147,63 @@ func TestDrivePreviewDownloadUsesResolvedTypeCodeAndRenamePolicy(t *testing.T) {
}
}
// TestDrivePreviewSourceFileDirectDownloadSkipsPreviewResult verifies
// source_file downloads the source file artifact without first fetching preview
// candidates.
func TestDrivePreviewSourceFileDirectDownloadSkipsPreviewResult(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/file_source/preview_download?preview_type=16",
Status: 200,
Body: []byte("# markdown\n"),
Headers: http.Header{
"Content-Disposition": []string{`attachment; filename="README.md"`},
"Content-Type": []string{"text/plain; charset=utf-8"},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DrivePreview, []string{
"+preview",
"--file-token", "file_source",
"--type", "source_file",
"--output", "artifacts/",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if _, ok := data["requested_type"]; ok {
t.Fatalf("requested_type should be omitted from execute output: %#v", data)
}
if got := data["selected_type"]; got != "source_file" {
t.Fatalf("selected_type=%v, want source_file", got)
}
if _, ok := data["selected_type_code"]; ok {
t.Fatalf("selected_type_code should be omitted from execute output: %#v", data)
}
resolvedTmpDir, err := filepath.EvalSymlinks(tmpDir)
if err != nil {
t.Fatalf("EvalSymlinks() error: %v", err)
}
wantPath := filepath.Join(resolvedTmpDir, "artifacts", "README.md")
if got := data["output_path"]; got != wantPath {
t.Fatalf("output_path=%v, want %s", got, wantPath)
}
gotBody, err := os.ReadFile(wantPath)
if err != nil {
t.Fatalf("ReadFile(%q) error: %v", wantPath, err)
}
if string(gotBody) != "# markdown\n" {
t.Fatalf("saved body=%q, want markdown source", string(gotBody))
}
}
// TestDrivePreviewRejectsUnavailableType verifies unavailable preview types
// return an actionable validation error.
func TestDrivePreviewRejectsUnavailableType(t *testing.T) {
@@ -434,6 +491,72 @@ func TestDrivePreviewDryRunIncludesVersionAndMode(t *testing.T) {
}
}
// TestDrivePreviewDryRunSourceFileDocumentsDirectDownload verifies source_file
// dry-run documents the direct source artifact download path.
func TestDrivePreviewDryRunSourceFileDocumentsDirectDownload(t *testing.T) {
runtime := newDrivePreviewRuntime(t, "drive +preview", map[string]string{
"file-token": "file_source",
"type": "source_file",
"version": "7",
"output": "source",
}, nil)
data := decodeDryRunOutput(t, DrivePreview.DryRun(context.Background(), runtime))
if got := data["mode"]; got != "download" {
t.Fatalf("mode=%v, want download", got)
}
if got := data["requested_type"]; got != "source_file" {
t.Fatalf("requested_type=%v, want source_file", got)
}
if got := data["selected_type"]; got != "source_file" {
t.Fatalf("selected_type=%v, want source_file", got)
}
if got := data["selected_type_code"]; got != drivePreviewTypeSourceFile {
t.Fatalf("selected_type_code=%v, want %s", got, drivePreviewTypeSourceFile)
}
api, _ := data["api"].([]interface{})
if len(api) != 1 {
t.Fatalf("len(api)=%d, want 1", len(api))
}
call, _ := api[0].(map[string]interface{})
if got := call["method"]; got != "GET" {
t.Fatalf("method=%v, want GET", got)
}
if got := call["url"]; got != "/open-apis/drive/v1/medias/file_source/preview_download" {
t.Fatalf("url=%v, want preview_download", got)
}
params, _ := call["params"].(map[string]interface{})
if got := params["preview_type"]; got != drivePreviewTypeSourceFile {
t.Fatalf("params.preview_type=%v, want %s", got, drivePreviewTypeSourceFile)
}
if got := params["version"]; got != "7" {
t.Fatalf("params.version=%v, want 7", got)
}
}
// TestDrivePreviewDryRunSourceAliasUsesPreviewCandidates verifies only the
// explicit source_file request bypasses preview_result.
func TestDrivePreviewDryRunSourceAliasUsesPreviewCandidates(t *testing.T) {
runtime := newDrivePreviewRuntime(t, "drive +preview", map[string]string{
"file-token": "file_source",
"type": "source",
"output": "source",
}, nil)
data := decodeDryRunOutput(t, DrivePreview.DryRun(context.Background(), runtime))
api, _ := data["api"].([]interface{})
if len(api) != 2 {
t.Fatalf("len(api)=%d, want 2", len(api))
}
call, _ := api[0].(map[string]interface{})
if got := call["url"]; got != "/open-apis/drive/v1/medias/file_source/preview_result" {
t.Fatalf("url=%v, want preview_result", got)
}
if _, ok := data["selected_type_code"]; ok {
t.Fatalf("selected_type_code should be omitted for non-source_file dry-run: %#v", data)
}
}
// TestDrivePreviewDryRunListOmitsBodyWithoutVersion verifies list-mode DryRun
// omits the request body when no version is supplied.
func TestDrivePreviewDryRunListOmitsBodyWithoutVersion(t *testing.T) {
@@ -612,6 +735,135 @@ func TestDrivePreviewNotReadyReturnsFailedPrecondition(t *testing.T) {
}
}
// TestDrivePreviewListOnlyErrorAddsSourceFileHint verifies preview_result API
// failures keep server diagnostics while guiding callers to source_file.
func TestDrivePreviewListOnlyErrorAddsSourceFileHint(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/file_markdown/preview_result",
Body: map[string]interface{}{
"code": 1,
"msg": "fail:mGetFilePreviewCore failed",
"log_id": "log-preview-result",
"error": map[string]interface{}{
"troubleshooter": "https://open.feishu.cn/document/troubleshoot/preview-result",
"details": []interface{}{
map[string]interface{}{"value": "server preview_result detail"},
},
},
},
})
err := mountAndRunDrive(t, DrivePreview, []string{
"+preview",
"--file-token", "file_markdown",
"--list-only",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected preview_result error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Category != errs.CategoryAPI {
t.Fatalf("category=%q, want api", problem.Category)
}
if problem.Code != 1 {
t.Fatalf("code=%d, want 1", problem.Code)
}
if problem.LogID != "log-preview-result" {
t.Fatalf("log_id=%q, want log-preview-result", problem.LogID)
}
if problem.Troubleshooter != "https://open.feishu.cn/document/troubleshoot/preview-result" {
t.Fatalf("troubleshooter=%q, want passthrough", problem.Troubleshooter)
}
if !strings.Contains(problem.Hint, "server preview_result detail") {
t.Fatalf("hint=%q, want server detail preserved", problem.Hint)
}
if !strings.Contains(problem.Hint, "--type source_file") || !strings.Contains(problem.Hint, "--output") {
t.Fatalf("hint=%q, want source_file output guidance", problem.Hint)
}
}
// TestDrivePreviewListOnlyRateLimitKeepsOriginalHint verifies retryable API
// errors are not reframed as source_file recovery.
func TestDrivePreviewListOnlyRateLimitKeepsOriginalHint(t *testing.T) {
err := withDrivePreviewSourceFileHint(errs.NewAPIError(errs.SubtypeRateLimit, "request trigger frequency limit").WithCode(99991400).WithRetryable())
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Hint != "" {
t.Fatalf("hint=%q, want empty hint for rate limit", problem.Hint)
}
if !problem.Retryable {
t.Fatal("retryable=false, want true")
}
}
// TestDrivePreviewSourceFileHintGuards verifies source_file recovery guidance
// only rewrites eligible API errors and preserves existing source_file hints.
func TestDrivePreviewSourceFileHintGuards(t *testing.T) {
plainErr := errors.New("plain failure")
if got := withDrivePreviewSourceFileHint(plainErr); got != plainErr {
t.Fatalf("non-API error changed: got %T %v, want original", got, got)
}
for _, tt := range []struct {
name string
err *errs.APIError
want string
}{
{
name: "already has source file hint",
err: errs.NewAPIError(errs.SubtypeServerError, "preview_result failed").WithHint("rerun with --type source_file --output <path>"),
want: "rerun with --type source_file --output <path>",
},
{
name: "candidate core failure empty hint",
err: errs.NewAPIError(errs.SubtypeServerError, "fail:mGetFilePreviewCore failed").WithCode(1),
want: drivePreviewSourceFileHint,
},
{
name: "candidate core failure whitespace hint",
err: errs.NewAPIError(errs.SubtypeServerError, "fail:mGetFilePreviewCore failed").WithCode(1).WithHint(" \n\t "),
want: drivePreviewSourceFileHint,
},
{
name: "generic server error",
err: errs.NewAPIError(errs.SubtypeServerError, "preview_result failed"),
want: "",
},
{
name: "not found",
err: errs.NewAPIError(errs.SubtypeNotFound, "file not found").WithCode(1061044),
want: "",
},
{
name: "invalid parameters",
err: errs.NewAPIError(errs.SubtypeInvalidParameters, "invalid file token").WithCode(1063007),
want: "",
},
} {
t.Run(tt.name, func(t *testing.T) {
gotErr := withDrivePreviewSourceFileHint(tt.err)
if gotErr != tt.err {
t.Fatalf("API error pointer changed: got %T, want original", gotErr)
}
problem, ok := errs.ProblemOf(gotErr)
if !ok {
t.Fatalf("expected typed error, got %T: %v", gotErr, gotErr)
}
if problem.Hint != tt.want {
t.Fatalf("hint=%q, want %q", problem.Hint, tt.want)
}
})
}
}
// TestDriveCoverRejectsUnknownSpec verifies unsupported cover specs produce a
// validation error with available alternatives.
func TestDriveCoverRejectsUnknownSpec(t *testing.T) {
@@ -721,6 +973,21 @@ func TestDrivePreviewCommonHelpers(t *testing.T) {
if path != "cover.pdf" || fallback != nil {
t.Fatalf("explicit ext append = (%q, %+v), want unchanged path", path, fallback)
}
header = http.Header{}
header.Set("Content-Type", "text/plain")
header.Set("Content-Disposition", `attachment; filename="README.md"`)
path, fallback = autoAppendDrivePreviewExtension("source", header, "")
if path != "source.md" || fallback == nil || fallback.Source != "Content-Disposition" {
t.Fatalf("source_file append = (%q, %+v), want source.md from Content-Disposition", path, fallback)
}
header = http.Header{}
header.Set("Content-Type", "text/plain")
path, fallback = autoAppendDrivePreviewExtension("source", header, "")
if path != "source.txt" || fallback == nil || fallback.Source != "Content-Type" {
t.Fatalf("source_file content-type append = (%q, %+v), want source.txt from Content-Type", path, fallback)
}
}
// TestDrivePreviewMetadataAndPathResolution verifies metadata normalization
@@ -751,7 +1018,7 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
runtime := newDrivePreviewRuntime(t, "drive +preview", nil, nil)
header := http.Header{}
header.Set("Content-Type", "application/pdf")
renamed, _, err := resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", drivePreviewIfExistsRename)
renamed, _, err := resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", drivePreviewIfExistsRename, "file_preview")
if err != nil {
t.Fatalf("resolveDrivePreviewOutputPath(rename) error: %v", err)
}
@@ -759,7 +1026,7 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
t.Fatalf("renamed=%q, want preview (1).pdf suffix", renamed)
}
_, _, err = resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", "keep")
_, _, err = resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", "keep", "file_preview")
if err == nil {
t.Fatal("expected invalid if-exists error, got nil")
}
@@ -771,6 +1038,20 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
t.Fatalf("param=%q, want --if-exists", validationErr.Param)
}
if err := os.Mkdir("artifacts", 0755); err != nil {
t.Fatalf("Mkdir() error: %v", err)
}
sourceHeader := http.Header{}
sourceHeader.Set("Content-Type", "text/plain")
sourceHeader.Set("Content-Disposition", `attachment; filename="README.md"`)
dirOutput, _, err := resolveDrivePreviewOutputPath(runtime, "artifacts", sourceHeader, "", drivePreviewIfExistsError, "file_source")
if err != nil {
t.Fatalf("resolveDrivePreviewOutputPath(directory) error: %v", err)
}
if !strings.HasSuffix(dirOutput, filepath.Join("artifacts", "README.md")) {
t.Fatalf("dirOutput=%q, want artifacts/README.md suffix", dirOutput)
}
unusedPath, err := nextAvailableDrivePreviewPath(runtime.FileIO(), "fresh.pdf")
if err != nil {
t.Fatalf("nextAvailableDrivePreviewPath(unused) error: %v", err)
@@ -779,7 +1060,7 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
t.Fatalf("unusedPath=%q, want fresh.pdf", unusedPath)
}
overwritten, _, err := resolveDrivePreviewOutputPath(runtime, "preview.pdf", header, ".pdf", drivePreviewIfExistsOverwrite)
overwritten, _, err := resolveDrivePreviewOutputPath(runtime, "preview.pdf", header, ".pdf", drivePreviewIfExistsOverwrite, "file_preview")
if err != nil {
t.Fatalf("resolveDrivePreviewOutputPath(overwrite) error: %v", err)
}
@@ -791,7 +1072,7 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
f.FileIOProvider = &statErrorProvider{inner: f.FileIOProvider, err: fs.ErrPermission}
runtimeWithStatErr := newDrivePreviewRuntime(t, "drive +preview", nil, nil)
runtimeWithStatErr.Factory = f
_, _, err = resolveDrivePreviewOutputPath(runtimeWithStatErr, "blocked.pdf", header, ".pdf", drivePreviewIfExistsError)
_, _, err = resolveDrivePreviewOutputPath(runtimeWithStatErr, "blocked.pdf", header, ".pdf", drivePreviewIfExistsError, "file_preview")
if err == nil {
t.Fatal("expected stat permission error, got nil")
}
@@ -876,7 +1157,6 @@ func TestDrivePreviewAliasAndAvailabilityHelpers(t *testing.T) {
if got := normalizeDrivePreviewRequest(" Source File "); got != "source_file" {
t.Fatalf("normalizeDrivePreviewRequest()=%q, want source_file", got)
}
aliases := previewAliasesForCandidate(drivePreviewCandidate{TypeCode: "1"})
if len(aliases) == 0 || aliases[0] != "image" {
t.Fatalf("previewAliasesForCandidate()=%v, want image alias", aliases)

View File

@@ -32,6 +32,7 @@ const (
markdownUploadPrepareAction = "initialize markdown multipart upload failed"
markdownUploadFinishAction = "finalize markdown multipart upload failed"
markdownFetchNameAction = "fetch existing markdown file name failed"
markdownSourceFilePreviewType = "16"
)
var markdownUploadRetryBackoffs = []time.Duration{
@@ -192,9 +193,14 @@ func resolveMarkdownOverwriteFileName(runtime *common.RuntimeContext, spec markd
}
func openMarkdownDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken string) (*http.Response, error) {
query, err := markdownSourceFilePreviewQuery("", "")
if err != nil {
return nil, err
}
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/medias/%s/preview_download", validate.EncodePathSegment(fileToken)),
QueryParams: query,
})
if err != nil {
return nil, wrapMarkdownDownloadError(err)
@@ -230,15 +236,15 @@ func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec)
return size, nil
}
func openMarkdownDownloadVersion(ctx context.Context, runtime *common.RuntimeContext, fileToken, version string) (*http.Response, string, error) {
req := &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
func openMarkdownDownloadVersion(ctx context.Context, runtime *common.RuntimeContext, fileToken, version, versionParam string) (*http.Response, string, error) {
query, err := markdownSourceFilePreviewQuery(version, versionParam)
if err != nil {
return nil, "", err
}
if strings.TrimSpace(version) != "" {
req.QueryParams = larkcore.QueryParams{
"version": []string{strings.TrimSpace(version)},
}
req := &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/medias/%s/preview_download", validate.EncodePathSegment(fileToken)),
QueryParams: query,
}
resp, err := runtime.DoAPIStream(ctx, req)
@@ -248,6 +254,58 @@ func openMarkdownDownloadVersion(ctx context.Context, runtime *common.RuntimeCon
return resp, fileNameFromDownloadHeader(resp.Header, fileToken+".md"), nil
}
func markdownSourceFilePreviewQuery(version, versionParam string) (larkcore.QueryParams, error) {
if err := validateMarkdownSourceFilePreviewVersion(version, versionParam); err != nil {
return nil, err
}
query := larkcore.QueryParams{
"preview_type": []string{markdownSourceFilePreviewType},
}
if version != "" {
query["version"] = []string{version}
}
return query, nil
}
func markdownSourceFilePreviewDryRunParams(version, versionParam string) (map[string]interface{}, error) {
if err := validateMarkdownSourceFilePreviewVersion(version, versionParam); err != nil {
return nil, err
}
params := map[string]interface{}{
"preview_type": markdownSourceFilePreviewType,
}
if version != "" {
params["version"] = version
}
return params, nil
}
func markdownSourceFilePreviewDryRunParamsForValidatedVersion(version, versionParam string) map[string]interface{} {
params, err := markdownSourceFilePreviewDryRunParams(version, versionParam)
if err != nil {
// Shortcut validation runs before DryRun. If a caller bypasses that
// contract, preserve the supplied value instead of silently dropping it.
params = map[string]interface{}{
"preview_type": markdownSourceFilePreviewType,
"version": version,
}
}
return params
}
func validateMarkdownSourceFilePreviewVersion(version, flagName string) error {
if version == "" {
return nil
}
if strings.TrimSpace(version) != "" {
return nil
}
if flagName == "" {
flagName = "--version"
}
return markdownValidationParamError(flagName, "%s cannot be empty", flagName)
}
func markdownDryRunFileField(spec markdownUploadSpec) string {
if spec.FilePath != "" {
return "@" + spec.FilePath

View File

@@ -112,9 +112,8 @@ func validateMarkdownDiffSpec(runtime *common.RuntimeContext, spec markdownDiffS
}
func validateMarkdownDiffVersionValue(value, flagName string) error {
value = strings.TrimSpace(value)
if value == "" {
return markdownValidationParamError(flagName, "%s cannot be empty", flagName)
if err := validateMarkdownSourceFilePreviewVersion(value, flagName); err != nil {
return err
}
if !markdownDiffVersionRe.MatchString(value) {
return markdownValidationParamError(flagName, "%s must be a numeric version string", flagName)
@@ -134,31 +133,33 @@ func markdownDiffDryRun(spec markdownDiffSpec) *common.DryRunAPI {
switch markdownDiffMode(spec) {
case markdownDiffModeRemoteVsLocal:
if spec.FromVersion != "" {
dry.GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[1] Download the specified remote Markdown version").
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("[1] Download the specified remote Markdown source file preview artifact").
Set("file_token", spec.FileToken).
Params(map[string]interface{}{"version": spec.FromVersion})
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion(spec.FromVersion, "--from-version"))
} else {
dry.GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[1] Download the latest remote Markdown version").
Set("file_token", spec.FileToken)
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("[1] Download the latest remote Markdown source file preview artifact").
Set("file_token", spec.FileToken).
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion("", ""))
}
dry.Set("local_file", spec.FilePath)
dry.Set("mode", markdownDiffModeRemoteVsLocal)
default:
dry.GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[1] Download the base remote Markdown version").
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("[1] Download the base remote Markdown source file preview artifact").
Set("file_token", spec.FileToken).
Params(map[string]interface{}{"version": spec.FromVersion})
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion(spec.FromVersion, "--from-version"))
if spec.ToVersion != "" {
dry.GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[2] Download the target remote Markdown version").
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("[2] Download the target remote Markdown source file preview artifact").
Set("file_token", spec.FileToken).
Params(map[string]interface{}{"version": spec.ToVersion})
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion(spec.ToVersion, "--to-version"))
} else {
dry.GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[2] Download the latest remote Markdown version").
Set("file_token", spec.FileToken)
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("[2] Download the latest remote Markdown source file preview artifact").
Set("file_token", spec.FileToken).
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion("", ""))
}
dry.Set("mode", markdownDiffModeRemoteVsRemote)
}
@@ -166,8 +167,8 @@ func markdownDiffDryRun(spec markdownDiffSpec) *common.DryRunAPI {
return dry
}
func downloadMarkdownContent(ctx context.Context, runtime *common.RuntimeContext, fileToken, version string) (string, string, error) {
resp, fileName, err := openMarkdownDownloadVersion(ctx, runtime, fileToken, version)
func downloadMarkdownContent(ctx context.Context, runtime *common.RuntimeContext, fileToken, version, versionParam string) (string, string, error) {
resp, fileName, err := openMarkdownDownloadVersion(ctx, runtime, fileToken, version, versionParam)
if err != nil {
return "", "", err
}
@@ -446,8 +447,8 @@ var MarkdownDiff = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateMarkdownDiffSpec(runtime, markdownDiffSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
FromVersion: runtime.Str("from-version"),
ToVersion: runtime.Str("to-version"),
FilePath: strings.TrimSpace(runtime.Str("file")),
ContextLines: runtime.Int("context-lines"),
Format: runtime.Format,
@@ -456,8 +457,8 @@ var MarkdownDiff = common.Shortcut{
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return markdownDiffDryRun(markdownDiffSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
FromVersion: runtime.Str("from-version"),
ToVersion: runtime.Str("to-version"),
FilePath: strings.TrimSpace(runtime.Str("file")),
ContextLines: runtime.Int("context-lines"),
})
@@ -465,8 +466,8 @@ var MarkdownDiff = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := markdownDiffSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
FromVersion: runtime.Str("from-version"),
ToVersion: runtime.Str("to-version"),
FilePath: strings.TrimSpace(runtime.Str("file")),
ContextLines: runtime.Int("context-lines"),
}
@@ -487,7 +488,7 @@ var MarkdownDiff = common.Shortcut{
} else {
fromLabel += "@latest"
}
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion)
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion, "--from-version")
if err != nil {
return err
}
@@ -499,17 +500,17 @@ var MarkdownDiff = common.Shortcut{
}
default:
fromLabel = "a/" + spec.FileToken + "@version:" + spec.FromVersion
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion)
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion, "--from-version")
if err != nil {
return err
}
if spec.ToVersion != "" {
toLabel = "b/" + spec.FileToken + "@version:" + spec.ToVersion
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.ToVersion)
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.ToVersion, "--to-version")
} else {
toLabel = "b/" + spec.FileToken + "@latest"
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, "")
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, "", "")
}
if err != nil {
return err

View File

@@ -48,6 +48,73 @@ func TestMarkdownDiffRejectsToVersionWithoutFromVersion(t *testing.T) {
}
}
func TestMarkdownDiffRejectsBlankVersion(t *testing.T) {
tests := []struct {
name string
args []string
wantParam string
}{
{
name: "from version",
args: []string{
"+diff",
"--file-token", "box_md_diff",
"--from-version", " \t",
"--file", "./local.md",
},
wantParam: "--from-version",
},
{
name: "to version",
args: []string{
"+diff",
"--file-token", "box_md_diff",
"--from-version", "7633658129540910621",
"--to-version", " ",
},
wantParam: "--to-version",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
err := mountAndRunMarkdown(t, MarkdownDiff, tt.args, f, stdout)
requireMarkdownValidationParam(t, err, tt.wantParam)
if !strings.Contains(err.Error(), "cannot be empty") {
t.Fatalf("expected empty version validation error, got %v", err)
}
})
}
}
func TestMarkdownSourceFilePreviewParamsValidateAndPreserveVersion(t *testing.T) {
version := " 7633658129540910621 "
query, err := markdownSourceFilePreviewQuery(version, "--from-version")
if err != nil {
t.Fatalf("markdownSourceFilePreviewQuery() error: %v", err)
}
if got := query["version"]; len(got) != 1 || got[0] != version {
t.Fatalf("query version = %#v, want original %q", got, version)
}
params, err := markdownSourceFilePreviewDryRunParams(version, "--from-version")
if err != nil {
t.Fatalf("markdownSourceFilePreviewDryRunParams() error: %v", err)
}
if got := params["version"]; got != version {
t.Fatalf("dry-run version = %#v, want original %q", got, version)
}
_, err = markdownSourceFilePreviewQuery(" \n", "--from-version")
requireMarkdownValidationParam(t, err, "--from-version")
_, err = markdownSourceFilePreviewDryRunParams(" \t", "--to-version")
requireMarkdownValidationParam(t, err, "--to-version")
}
func TestMarkdownDiffMissingVersionAndFileNamesCandidateParams(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
@@ -79,7 +146,7 @@ func TestMarkdownDiffRemoteVsRemoteJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910621",
Status: 200,
RawBody: []byte("# Title\n\n- alpha\n- beta\n"),
Headers: http.Header{
@@ -88,7 +155,7 @@ func TestMarkdownDiffRemoteVsRemoteJSON(t *testing.T) {
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910628",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910628",
Status: 200,
RawBody: []byte("# Title\n\n- alpha\n- beta updated\n- gamma\n"),
Headers: http.Header{
@@ -151,7 +218,7 @@ func TestMarkdownDiffRemoteVsLocalPretty(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# Title\n\nhello old\n"),
Headers: http.Header{
@@ -191,7 +258,7 @@ func TestMarkdownDiffRejectsOversizedRemoteContent(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16",
Status: 200,
RawBody: bytes.Repeat([]byte("x"), markdownDiffMaxContentBytes+1),
})
@@ -218,7 +285,7 @@ func TestMarkdownDiffRejectsOversizedLocalContent(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# Title\n"),
})
@@ -337,7 +404,7 @@ func TestMarkdownDiffRemoteVsRemoteJSONMultipleHunks(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910621",
Status: 200,
RawBody: []byte("line1\nline2\nline3\nline4\nline5\nline6\n"),
Headers: http.Header{
@@ -346,7 +413,7 @@ func TestMarkdownDiffRemoteVsRemoteJSONMultipleHunks(t *testing.T) {
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910628",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910628",
Status: 200,
RawBody: []byte("line1\nline2 changed\nline3\nline4\nline5 changed\nline6\n"),
Headers: http.Header{
@@ -398,13 +465,13 @@ func TestMarkdownDiffNoChangesPretty(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910621",
Status: 200,
RawBody: []byte("# Title\n"),
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# Title\n"),
})
@@ -445,8 +512,11 @@ func TestMarkdownDiffDryRunRemoteVsLocal(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "/open-apis/drive/v1/files/:file_token/download") && !strings.Contains(stdout.String(), "/open-apis/drive/v1/files/box_md_diff/download") {
t.Fatalf("dry-run missing download call: %s", stdout.String())
if !strings.Contains(stdout.String(), "/open-apis/drive/v1/medias/box_md_diff/preview_download") {
t.Fatalf("dry-run missing source preview download call: %s", stdout.String())
}
if !strings.Contains(stdout.String(), `"preview_type": "16"`) {
t.Fatalf("dry-run missing source_file preview_type: %s", stdout.String())
}
if !strings.Contains(stdout.String(), `"local_file": "local.md"`) && !strings.Contains(stdout.String(), `"local_file": "./local.md"`) {
t.Fatalf("dry-run missing local file metadata: %s", stdout.String())

View File

@@ -5,14 +5,10 @@ package markdown
import (
"context"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
@@ -47,8 +43,9 @@ var MarkdownFetch = common.Shortcut{
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
dry := common.NewDryRunAPI().
Desc("download markdown file bytes; when --output is omitted the CLI returns content as UTF-8 text").
GET("/open-apis/drive/v1/files/:file_token/download").
Desc("download markdown source file preview artifact bytes; when --output is omitted the CLI returns content as UTF-8 text").
GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion("", "")).
Set("file_token", runtime.Str("file-token"))
if outputPath := strings.TrimSpace(runtime.Str("output")); outputPath != "" {
dry.Set("output", outputPath)
@@ -61,12 +58,9 @@ var MarkdownFetch = common.Shortcut{
fileToken := strings.TrimSpace(runtime.Str("file-token"))
outputPath := strings.TrimSpace(runtime.Str("output"))
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
})
resp, err := openMarkdownDownload(ctx, runtime, fileToken)
if err != nil {
return wrapMarkdownDownloadError(err)
return err
}
defer resp.Body.Close()

View File

@@ -62,8 +62,9 @@ var MarkdownPatch = common.Shortcut{
sizeThreshold := common.FormatSize(markdownSinglePartSizeLimit)
return common.NewDryRunAPI().
Desc("Download the current Markdown file, apply the replacement locally, and overwrite the file only when matches are found").
GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[1] Download the current Markdown content").
GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("[1] Download the current Markdown source file preview artifact").
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion("", "")).
Set("file_token", spec.FileToken).
POST("/open-apis/drive/v1/metas/batch_query").
Desc("[2] Read current file metadata to preserve the existing file name before overwrite").

View File

@@ -85,9 +85,12 @@ func TestMarkdownPatchDryRunLiteral(t *testing.T) {
if got := len(dry.API); got != 6 {
t.Fatalf("api steps = %d, want 6", got)
}
if got := dry.API[0].URL; got != "/open-apis/drive/v1/files/box_md_patch/download" {
if got := dry.API[0].URL; got != "/open-apis/drive/v1/medias/box_md_patch/preview_download" {
t.Fatalf("download url = %q", got)
}
if got := dry.API[0].Params["preview_type"]; got != markdownSourceFilePreviewType {
t.Fatalf("download preview_type = %#v", got)
}
if got := dry.API[1].URL; got != "/open-apis/drive/v1/metas/batch_query" {
t.Fatalf("metas url = %q", got)
}
@@ -120,7 +123,7 @@ func TestMarkdownPatchDryRunRegex(t *testing.T) {
if got := dry.Mode; got != markdownPatchModeRegex {
t.Fatalf("mode = %q, want %q", got, markdownPatchModeRegex)
}
if got := dry.API[0].Desc; !strings.Contains(got, "Download the current Markdown content") {
if got := dry.API[0].Desc; !strings.Contains(got, "Download the current Markdown source file preview artifact") {
t.Fatalf("download desc = %q", got)
}
if got := dry.API[3].Desc; !strings.Contains(got, "multipart overwrite upload") {
@@ -144,7 +147,7 @@ func TestMarkdownPatchReturnsSuccessWhenNothingMatches(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
})
@@ -187,7 +190,7 @@ func TestMarkdownPatchPrettyOutputWhenNothingMatches(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
})
@@ -224,7 +227,7 @@ func TestMarkdownPatchLiteralOverwrite(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# TODO\nTODO\n"),
Headers: map[string][]string{
@@ -299,7 +302,7 @@ func TestMarkdownPatchPrettyOutputWhenUpdated(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# TODO\n"),
Headers: map[string][]string{
@@ -360,7 +363,7 @@ func TestMarkdownPatchRegexOverwrite(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("Version: 12\nVersion: 34\n"),
})
@@ -429,7 +432,7 @@ func TestMarkdownPatchAllowsEmptyReplacement(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("hello world\n"),
})
@@ -478,7 +481,7 @@ func TestMarkdownPatchRejectsEmptyPatchedContent(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("hello\n"),
})
@@ -509,9 +512,10 @@ func decodeMarkdownEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]inter
type markdownPatchDryRunOutput struct {
Mode string `json:"mode"`
API []struct {
Desc string `json:"desc"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
Desc string `json:"desc"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}

View File

@@ -1984,7 +1984,7 @@ func TestMarkdownFetchReturnsContent(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{
@@ -2050,7 +2050,7 @@ func TestMarkdownFetchPrettyReturnsContent(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{
@@ -2078,7 +2078,7 @@ func TestMarkdownFetchSavesFile(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{
@@ -2122,7 +2122,7 @@ func TestMarkdownFetchRejectsExistingFileWithoutOverwrite(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{
@@ -2151,7 +2151,7 @@ func TestMarkdownFetchOverwritesExistingFileWhenRequested(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{
@@ -2189,7 +2189,7 @@ func TestMarkdownFetchSavesUsingRemoteNameWhenOutputIsExistingDirectory(t *testi
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{
@@ -2226,7 +2226,7 @@ func TestMarkdownFetchSavesUsingRemoteNameWhenOutputUsesDirectorySyntax(t *testi
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{
@@ -2260,7 +2260,7 @@ func TestMarkdownFetchPrettySavesFile(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{
@@ -2295,7 +2295,7 @@ func TestMarkdownFetchSaveFailure(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{

View File

@@ -1,7 +1,7 @@
---
name: lark-base
version: 1.2.3
description: "飞书多维表格Base操作建表、字段、记录、视图、统计、公式/lookup、表单、仪表盘、workflow、角色权限遇到 Base/多维表格/bitable 或 /base/ 链接时使用。文件导入转 lark-drive认证/授权转 lark-shared。"
description: "飞书多维表格Base操作建表、字段、记录、视图、统计、公式/lookup、表单、仪表盘、workflow、角色权限遇到 Base/多维表格/bitable 或 /base/ 链接时使用。文件导入/导出转 lark-drive认证/授权转 lark-shared。"
metadata:
requires:
bins: ["lark-cli"]
@@ -23,14 +23,15 @@ metadata:
不要使用本 skill
- 只是认证、初始化配置、切换身份、处理 scope 或权限授权恢复,转 `lark-shared`
- 把本地 Excel / CSV / `.base` 导入成 Base`lark-drive +import --type bitable`
- 把本地文件导入成 Base或将 Base 导出为本地文件,转 `lark-drive`
- 泛化数据分析、字段设计、公式讨论,但没有 Base/多维表格上下文。
## 使用边界
- Base 业务操作只使用 `lark-cli base +...` shortcut不使用旧聚合式 `+table / +field / +record / +view / +history / +workspace`
- 执行 update 前必须先查当前 shortcut 的 `--help` 或对应 reference。若命令要求完整配置首次请求必须基于可信的当前配置执行 read-modify-write只修改用户明确指定的内容保留其他仍适用的可写配置并按命令要求的结构提交。若命令支持局部delta update按其契约提交最小合法 payload不得以不完整请求试错补参。
- 用户要把 Excel / CSV / `.base` 导入成 Base 时,先`lark-cli drive +import --type bitable`导入完成后再回到 Base 命令。
- 本地文件与 Base 之间的导入/导出`lark-drive`,具体格式、参数、路径限制和仅结构导出规则由 `lark-drive` 负责;导入完成后再回到 Base 命令。
- 在线复制 Base 使用 `+base-copy`,不要绕行导出/导入。
- 认证、初始化、scope、身份切换、权限不足恢复属于 `lark-shared`Base 文档只保留会影响 Base 路径选择的权限规则。
## 先获取 Base Token 和所需 ID
@@ -49,6 +50,7 @@ metadata:
|---|---|---|
| 查 Base 本体 | `+base-get` | 用返回确认 Base 名称、owner、权限和可继续操作的 token |
| 创建/复制 Base | `+base-create` / `+base-copy` | 新建时强烈推荐用 `--table-name` + `--fields` 同时配置新 Base 里唯一一个初始数据表的 name 和 schema写入后报告新 Base 标识和 `permission_grant` |
| Base 文件导入/导出 | 转 `lark-drive` | 文件格式、参数、路径限制和仅结构导出规则由 `lark-drive` 负责;在线复制走 `+base-copy` |
| 查看 Base 内资源目录 | `+base-block-list` | 想先了解一个 Base 里有哪些 table/docx/dashboard/workflow/folder 时优先用它;返回 ID 关系和 fewshot 看 `--help` |
| 管理 Base 内资源目录 | `+base-block-create/move/rename/delete` | 创建或整理 Base 直接管理的 folder/table/docx/dashboard/workflow资源内容继续用对应命令 |
| 管理数据表 | `+table-list/get/create/update/delete` | 处理 table 的列出、详情、创建、重命名和删除 |
@@ -63,8 +65,9 @@ metadata:
| 公式字段 | `+field-create/update --json '{"type":"formula",...}'` | 必读 [formula-field-guide.md](references/formula-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
| Lookup 字段 | `+field-create/update --json '{"type":"lookup",...}'` | 必读 [lookup-field-guide.md](references/lookup-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
| 表单提交 | `+form-submit` | 先读 [lark-base-form-detail.md](references/lark-base-form-detail.md) 获取题目、filter 和附件所需 `base_token`;提交 JSON 读 [lark-base-form-submit.md](references/lark-base-form-submit.md) |
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | 读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md);题目显隐条件 `visible_rule` 结构见公共协议 [lark-base-filter-condition.md](references/lark-base-filter-condition.md) |
| 其他表单管理 | `+form-list/get/detail/create/update/delete` / `+form-questions-list/delete` | `+form-detail` 读 [lark-base-form-detail.md](references/lark-base-form-detail.md)删除前确认目标表单 |
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | Base 内表单按 table 管理;先确定并复用真实 `table_id`读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md);题目显隐条件 `visible_rule` 结构见公共协议 [lark-base-filter-condition.md](references/lark-base-filter-condition.md) |
| Base 内表单管理 | `+form-list/get/create/update/delete` / `+form-questions-list/delete` | 缺少或不确定归属时,先用 `+table-list``+base-block-list` 取得真实 `table_id`;这些命令使用 `--base-token + --table-id` 并在整个工作流中复用同一 `table_id`删除前确认目标表单 |
| 分享表单详情 | `+form-detail --share-token <share_token>` | 只接受表单分享链接里的 `share_token`,不要传 `--base-token` / `--form-id`;提交前读 [lark-base-form-detail.md](references/lark-base-form-detail.md) |
| 仪表盘与组件 | `+dashboard-*` / `+dashboard-block-*` | 提到图表/看板/block 时先读 [lark-base-dashboard.md](references/lark-base-dashboard.md);组件 `data_config` 读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md);读取图表计算结果用 `+dashboard-block-get-data` |
| Workflow | `+workflow-*` | 创建/更新或理解 steps 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md)list/get/enable/disable 只处理 workflow ID 与启停状态 |
| 高级权限与角色 | `+advperm-*` / `+role-*` | 角色操作先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md);角色 create/update 或解读完整配置再读权限 JSON SSOT [role-config.md](references/role-config.md);系统角色不可删除;关闭高级权限会影响自定义角色 |
@@ -116,6 +119,9 @@ metadata:
## 表单与视图细节
- Base 内表单 list/get/create/update/delete 和题目管理都属于具体数据表:第一个管理命令前必须已有归属明确的真实 `table_id`;缺失或归属不明确时才用 `+table-list``+base-block-list` 定位,已有真实 ID 时直接复用。后续管理命令始终传同一 `base_token + table_id``+form-detail` 是分享表单入口,标识域不同,只使用 `share_token`
- 表单问题由数据表字段承载question `id` 就是 `field_id`。创建问题前先 `+form-questions-list`;除非用户明确要求同名的独立问题,否则标题已存在时优先用 `+form-questions-update` 修改必填状态、标题或描述,不要先创建同名问题再删除旧问题。
- `+form-questions-delete` 会删除承载问题的数据表字段。主字段问题不可删除;不要把主字段 ID 放入 `--question-ids`,需要修改时使用 `+form-questions-update`
- `+form-submit` 是高风险写操作,必须带 `--yes` 确认;调用前必须先跑 `+form-detail`,读取 `questions[].type``required``filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。
- `+form-questions-update` 是题目配置全量覆盖,不是 patch未传字段会回落默认值传空字符串 / `null` / 空数组会直接写入空或清空。更新前先 `+form-questions-list` 读取当前题目,把要保留的 `title` / `description` / `required` / `option_display_mode` / `visible_rule` 等字段带回请求。
- 表单附件不要写进 `fields`,放在 `--json.attachments`;提交附件时必须同时传表单所属 Base 的 `--base-token`

View File

@@ -137,9 +137,12 @@ lark-cli base +form-questions-create \
> [!CAUTION]
> 这是**写入操作** — 执行前必须向用户确认。
1.`+form-questions-list` 查看现有问题
2. 确认要添加的问题内容
3. 执行命令并报告新建的问题 ID
1.确定表单所属的真实 `table_id`,并在整个表单管理工作流中复用它;仅在 ID 缺失或归属不明确时调用 `+table-list`
2. `+form-questions-list` 查看现有问题。问题 `id` 是承载该问题的 `field_id`,不是独立于数据表的临时 ID。
3. 除非用户明确要求同名的独立问题,否则目标标题已经存在时用 `+form-questions-update` 更新必填状态、标题或描述;不要创建同名问题后再删除旧问题。
4. 创建确实不存在的问题,或用户明确要求的同名独立问题,并报告新建的问题 ID。
`+form-questions-delete` 会删除承载问题的数据表字段,不能删除主字段问题。不要通过“新建重复问题再删除旧问题”来替换主字段。
## 参考

View File

@@ -6,6 +6,7 @@ This guide is the entry point for Base advanced permissions and roles. Use it to
| Goal | Command | Notes |
|------|---------|-------|
| Check advanced permission status | `+base-get` | Read `data.base.is_advanced`. There is no `+advperm-get` command. |
| Enable advanced permissions | `+advperm-enable` | Required before creating or updating roles. Caller must be a Base admin. |
| Disable advanced permissions | `+advperm-disable` | High-risk write. Disabling invalidates existing custom roles. |
| Locate roles | `+role-list` | Returns role summaries. Use `+role-get` for full config. |
@@ -14,6 +15,16 @@ This guide is the entry point for Base advanced permissions and roles. Use it to
| Update a role | `+role-update` | Delta merge. Read current config first, then send only intended changes. |
| Delete a role | `+role-delete` | Custom roles only. System roles cannot be deleted. |
## Required order
At the start of a role workflow, before the first `+role-list`, `+role-get`, `+role-create`, `+role-update`, or `+role-delete` call:
1. Run `lark-cli base +base-get --base-token <base_token>` and inspect `data.base.is_advanced`.
2. If `is_advanced` is `false`, run `+advperm-enable` before the role command. If the user did not authorize enabling advanced permissions, stop and explain the required precondition.
3. Run the requested role commands only after `is_advanced` is `true` or `+advperm-enable` succeeds. Reuse that confirmed status for later role calls in the same workflow.
Do not probe with `+advperm-get`: that command is not supported. Do not use an empty `+role-list` response to infer the advanced permission status; a disabled Base can also return an empty list.
## Safety boundaries
- Role operations require advanced permissions to be enabled and the caller to be a Base admin.

View File

@@ -154,12 +154,34 @@
"table_rule_map": {
"订单表": {
"perm": "edit",
"view_rule": { "..." : "..." },
"record_rule": { "..." : "..." },
"field_rule": { "..." : "..." }
"view_rule": {
"allow_edit": true,
"visibility": { "all_visible": true }
},
"record_rule": {
"record_operations": ["add", "delete"],
"other_record_all_read": true
},
"field_rule": {
"field_perm_mode": "all_edit"
}
},
"用户表": {
"perm": "read_only"
"perm": "read_only",
"view_rule": {
"allow_edit": false,
"visibility": { "all_visible": true }
},
"record_rule": {
"record_operations": [],
"other_record_all_read": true
},
"field_rule": {
"field_perm_mode": "all_read"
}
},
"内部表": {
"perm": "no_perm"
}
}
}
@@ -172,7 +194,11 @@
| `record_rule` | RecordRule | 记录权限配置 |
| `field_rule` | FieldRule | 字段权限配置 |
**注意**: 当 `perm``no_perm` 时,`view_rule``record_rule``field_rule` 均无须再设置。
**`+role-create` 硬约束**:
-`perm``no_perm` 时,不要设置 `view_rule``record_rule``field_rule`
-`perm` 为其他值时,必须同时提供完整的 `view_rule``record_rule``field_rule`,缺少任意一项都会导致创建失败。
- `+role-update` 是 delta merge只提交要修改的字段不要为局部更新补造未变更配置。
---

View File

@@ -43,7 +43,7 @@ metadata:
- 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history``drive +version-get``drive +version-revert``drive +version-delete`;这组命令同时支持 `--as user``--as bot`,自动化场景优先 `--as bot`
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`
- 用户要在云空间(云盘/云存储)里新建文件夹,优先使用 `lark-cli drive +create-folder`
- 用户要查看某个文件有哪些可下载预览格式,或想下载 PDF / HTML / 文本 / 图片等预览产物,使用 `lark-cli drive +preview`
- 用户要查看或下载文件内容,或者查看文件可用预览格式并获取 PDF / HTML / 文本 / 图片等转换预览产物,使用 `lark-cli drive +preview`
- 用户要获取某个文件的封面图,优先使用 `lark-cli drive +cover`;先 `--list-only` 看规格,再选 `--spec` 下载。
- 用户要导出云文档时,优先使用 `lark-cli drive +export --url '<文档 URL>' --file-extension <格式>`详细参数、Wiki token 和错误码处理见 [`references/lark-drive-export.md`](references/lark-drive-export.md)。
- 用户要把本地文件上传到知识库 / 文档库里的某个 wiki 节点下时,仍然使用 `lark-cli drive +upload --wiki-token <wiki_token>`;不要误切到 `wiki` 域命令。
@@ -121,7 +121,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| [`+upload`](references/lark-drive-upload.md) | 上传本地文件到 Drive 文件夹或 wiki 节点;修改/重写/更新已有文件时优先覆盖上传,而不是直接上传一个新文件。 |
| [`+create-folder`](references/lark-drive-create-folder.md) | 新建 Drive 文件夹,支持父文件夹与 bot 创建后自动授权。 |
| [`+download`](references/lark-drive-download.md) | 下载 Drive 文件到本地。 |
| [`+preview`](references/lark-drive-preview.md) | 查看或下载文件 PDF / HTML / 文本 / 图片等预览产物。 |
| [`+preview`](references/lark-drive-preview.md) | 查看或下载文件内容,或者查看文件可用预览格式并获取 PDF / HTML / 文本 / 图片等转换预览产物。 |
| [`+cover`](references/lark-drive-cover.md) | 查看或下载文件封面图规格。 |
| [`+status`](references/lark-drive-status.md) | 比较本地目录与 Drive 文件夹差异;默认按 SHA-256 精确比较,`--quick` 使用修改时间近似比较。 |
| [`+pull`](references/lark-drive-pull.md) | 从 Drive 拉取文件到本地目录,支持重复远端路径处理和增量模式。 |

View File

@@ -25,6 +25,10 @@ https://xxx.feishu.cn/drive/file/boxbc_xxx
file_token
```
## 排障
- 如果返回 `HTTP 403`,可以使用 [lark-drive-preview](lark-drive-preview.md) 下载源文件产物。
## 参考
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令

View File

@@ -2,15 +2,24 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、权限处理和安全规则。
列出或下载 Drive 文件可用的预览产物。这个 shortcut 不猜测默认类型:
查看或下载 Drive 文件内容,或列出并获取文件可用的预览产物。这个 shortcut 不猜测默认类型:
- 如果只需要查看或下载文件内容,或不关心 PDF/text/image 等转换预览,优先使用 `--type source_file --output <path>`
- 只想看候选项时,用 `--list-only`
- 如果需要服务端生成的预览效果,例如 doc/docx 的 PDF 版式预览,先用 `--list-only` 查看候选项,再按候选项选择 `--type pdf` / `text` / `image`
- 想下载时,必须显式传 `--type``--output`
- 如果 `--list-only` 没有可用预览候选项,或错误提示明确建议使用 `--type source_file`,可以改用 `--type source_file --output <path>` 查看文件内容资源不存在、token 无效等终态错误需要先修正输入
- 如果某个候选项还在生成中,会返回结构化错误并提示先重新 `--list-only`
### 命令
```bash
# 查看文件内容
lark-cli drive +preview \
--file-token "<FILE_TOKEN>" \
--type source_file \
--output ./artifacts/source
# 列出可用预览候选项
lark-cli drive +preview \
--file-token "<FILE_TOKEN>" \
@@ -78,6 +87,7 @@ lark-cli drive +preview \
- 不传 `--list-only` 时,必须显式传 `--type``--output`
- 不会隐式选择“第一个候选项”作为默认下载目标
- `--type source_file` 用于查看文件内容,不依赖 `--list-only` 返回的候选项;它适合读取或保存源内容,不等同于 PDF/text/image 等转换预览
- 候选项状态来自后端 `preview_status` 枚举,例如 `READY` / `PROCESSING` / `FAILED` / `NO_SUPPORT`
- 本地文件名在未显式带扩展名时,会结合响应头自动补扩展名

View File

@@ -55,3 +55,26 @@ func TestBaseFormDetailDryRun_MissingShareToken(t *testing.T) {
assert.NotEqual(t, 0, result.ExitCode)
assert.Contains(t, result.Stderr, "share-token")
}
func TestBaseFormListDryRun_UsesBaseAndTableIdentifiers(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+form-list",
"--base-token", "basXXXX",
"--table-id", "tblXXXX",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/base/v3/bases/basXXXX/tables/tblXXXX/forms")
assert.Contains(t, output, `"method": "GET"`)
}

View File

@@ -0,0 +1,108 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestBaseFormQuestionsCreateDryRun(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+form-questions-create",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--form-id", "vew_x",
"--questions", `[{"type":"text","title":"Risk","required":true}]`,
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_x/questions", clie2e.DryRunGet(out, "api.0.url").String(), out)
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), out)
require.Equal(t, "text", clie2e.DryRunGet(out, "api.0.body.questions.0.type").String(), out)
require.Equal(t, "Risk", clie2e.DryRunGet(out, "api.0.body.questions.0.title").String(), out)
require.True(t, clie2e.DryRunGet(out, "api.0.body.questions.0.required").Bool(), out)
}
func TestBaseFormQuestionsCreateDryRunRejectsInvalidInput(t *testing.T) {
setBaseDryRunConfigEnv(t)
tests := []struct {
name string
input string
message string
}{
{name: "malformed JSON", input: "{", message: "must be a valid JSON array"},
{name: "non-array JSON", input: "{}", message: "must be a valid JSON array"},
{name: "null", input: "null", message: "must be a non-null JSON array"},
{name: "non-object item", input: "[1]", message: "item 1 must be an object"},
{name: "missing title", input: `[{"type":"text"}]`, message: `item 1 must include a non-empty string "title"`},
{name: "blank title", input: `[{"title":" ","type":"text"}]`, message: `item 1 must include a non-empty string "title"`},
{name: "missing type", input: `[{"title":"Risk"}]`, message: `item 1 must include a non-empty string "type"`},
{name: "non-string type", input: `[{"title":"Risk","type":1}]`, message: `item 1 must include a non-empty string "type"`},
{name: "more than ten items", input: `[{},{},{},{},{},{},{},{},{},{},{}]`, message: "must contain at most 10 items"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+form-questions-create",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--form-id", "vew_x",
"--questions", tt.input,
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), result.Stderr)
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), result.Stderr)
require.Equal(t, "--questions", gjson.Get(result.Stderr, "error.param").String(), result.Stderr)
require.Contains(t, gjson.Get(result.Stderr, "error.message").String(), tt.message)
require.Empty(t, result.Stdout)
})
}
}
func TestBaseFormQuestionsCreateHelpShowsExistingQuestionGuard(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+form-questions-create", "--help"},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Contains(t, strings.ToLower(result.Stdout), "form may already contain questions")
require.Contains(t, result.Stdout, "+form-questions-list")
require.Contains(t, result.Stdout, "+form-questions-update")
}

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"path/filepath"
"runtime"
"testing"
"github.com/larksuite/cli/internal/vfs"
"github.com/stretchr/testify/require"
)
func TestBaseSkillRoutesFileImportExportToDrive(t *testing.T) {
_, currentFile, _, ok := runtime.Caller(0)
require.True(t, ok)
skillPath := filepath.Join(filepath.Dir(currentFile), "..", "..", "..", "skills", "lark-base", "SKILL.md")
content, err := vfs.ReadFile(skillPath)
require.NoError(t, err)
skill := string(content)
require.Contains(t, skill, "文件导入/导出转 lark-drive")
require.Contains(t, skill, "本地文件与 Base 之间的导入/导出转 `lark-drive`")
require.Contains(t, skill, "在线复制走 `+base-copy`")
require.NotContains(t, skill, "--only-schema")
require.NotContains(t, skill, "--output-dir")
require.NotContains(t, skill, "/tmp/")
}

View File

@@ -1,17 +1,21 @@
# Base CLI E2E Coverage
## Metrics
- Denominator: 78 leaf commands
- Covered: 22
- Coverage: 28.2%
- Denominator: 87 leaf commands
- Covered: 28
- Coverage: 32.2%
## Summary
- TestBase_BasicWorkflow: proves `+base-create`, `+base-get`, `+table-create`, `+table-get`, and `+table-list`; key `t.Run(...)` proof points are `get base as bot`, `get table as bot`, and `list tables and find created table as bot`.
- TestBaseBlockDryRun: proves the five `+base-block-*` shortcuts request shapes without touching live data.
- TestBaseFieldCreateDryRunArrayCompat: proves `+field-create` dry-run request shape for the internal JSON-array compatibility path.
- TestBaseFormQuestionsCreateDryRun: proves `+form-questions-create` preserves its POST body and renders the existing-question guard in command help.
- TestBaseFormDetailDryRun / TestBaseFormSubmitDryRun: prove shared-form detail and submission request shapes.
- TestBaseDashboardBlockGetDataDryRun: proves dashboard block data request shapes and identifier handling.
- TestBaseRecordBatchUpdatePerRecordDryRun: proves `+record-batch-update` preserves the per-record `update_records` request shape.
- TestBaseRecordBatchUpdatePerRecordWorkflow: creates two records, updates different field types in one request, asserts the minimal response contract, reads both records back, verifies a missing record ID is not prevalidated, and cleans up the temporary Base.
- TestBase_RoleWorkflow: proves `+advperm-enable`, `+role-create`, `+role-list`, `+role-get`, and `+role-update`; key `t.Run(...)` proof points are `list as bot`, `get as bot`, and `update as bot`.
- TestBaseFormListDryRun_UsesBaseAndTableIdentifiers: proves `+form-list` dry-run request shape uses Base and table identifiers in the endpoint.
- TestBaseFormQuestionsCreateVisibleRuleDryRun / TestBaseFormQuestionsUpdateVisibleRuleDryRun: prove `+form-questions-create` / `+form-questions-update` dry-run request shape and that the optional `visible_rule` display condition is transcribed verbatim into the request body.
- Cleanup note: `+table-delete` and `+role-delete` only run in cleanup and are intentionally left uncovered.
- Blocked area: dashboard, field, most record operations, form, view, and workflow operations still lack deterministic create/read/update workflows in this suite.
@@ -34,6 +38,7 @@
| ✕ | base +dashboard-block-create | shortcut | | none | dashboard workflows not covered |
| ✕ | base +dashboard-block-delete | shortcut | | none | dashboard workflows not covered |
| ✕ | base +dashboard-block-get | shortcut | | none | dashboard workflows not covered |
| ✓ | base +dashboard-block-get-data | shortcut | base_dashboard_block_get_data_dryrun_test.go | `--base-token`; `--dashboard-id`; `--block-id`; dry-run only | request shape and identifier handling |
| ✕ | base +dashboard-block-list | shortcut | | none | dashboard workflows not covered |
| ✕ | base +dashboard-block-update | shortcut | | none | dashboard workflows not covered |
| ✕ | base +dashboard-create | shortcut | | none | dashboard workflows not covered |
@@ -50,12 +55,14 @@
| ✕ | base +field-update | shortcut | | none | field workflows not covered |
| ✕ | base +form-create | shortcut | | none | form workflows not covered |
| ✕ | base +form-delete | shortcut | | none | form workflows not covered |
| ✓ | base +form-detail | shortcut | base_form_detail_dryrun_test.go::TestBaseFormDetailDryRun | `--share-token`; dry-run only | shared-form request shape |
| ✕ | base +form-get | shortcut | | none | form workflows not covered |
| | base +form-list | shortcut | | none | form workflows not covered |
| ✓ | base +form-questions-create | shortcut | TestBaseFormQuestionsCreateVisibleRuleDryRun | questions[].visible_rule | dry-run: request shape + visible_rule body passthrough |
| | base +form-list | shortcut | base_form_detail_dryrun_test.go::TestBaseFormListDryRun_UsesBaseAndTableIdentifiers | `--base-token`; `--table-id`; dry-run only | request shape only |
| ✓ | base +form-questions-create | shortcut | TestBaseFormQuestionsCreateVisibleRuleDryRun; base_form_questions_create_dryrun_test.go | questions[].visible_rule; dry-run | request body, visible_rule passthrough, and help guard covered |
| ✕ | base +form-questions-delete | shortcut | | none | form workflows not covered |
| ✕ | base +form-questions-list | shortcut | | none | form workflows not covered |
| ✓ | base +form-questions-update | shortcut | TestBaseFormQuestionsUpdateVisibleRuleDryRun | questions[].visible_rule | dry-run: request shape + visible_rule body passthrough |
| ✓ | base +form-submit | shortcut | base_form_submit_dryrun_test.go::TestBaseFormSubmitDryRun | `--share-token`; `--json`; dry-run only | submission request shape |
| ✕ | base +form-update | shortcut | | none | form workflows not covered |
| ✓ | base +record-batch-create | shortcut | base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--base-token`; `--table-id`; `--json.create_records` | seeds heterogeneous live workflow records |
| ✓ | base +record-batch-update | shortcut | base_record_batch_update_dryrun_test.go::TestBaseRecordBatchUpdatePerRecordDryRun; base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--base-token`; `--table-id`; `--json.update_records`; dry-run + live | heterogeneous select/number update with write-back verification |
@@ -64,6 +71,7 @@
| ✕ | base +record-history-list | shortcut | | none | record workflows not covered |
| ✕ | base +record-list | shortcut | | none | record workflows not covered |
| ✕ | base +record-search | shortcut | | none | record workflows not covered |
| ✕ | base +record-share-link-create | shortcut | | none | record workflows not covered |
| ✓ | base +record-upload-attachment | shortcut | base_attachment_dryrun_test.go::TestBase_AttachmentDryRun/upload | dry-run only | request shape only |
| ✓ | base +record-download-attachment | shortcut | base_attachment_dryrun_test.go::TestBase_AttachmentDryRun/download | dry-run only | request shape only |
| ✓ | base +record-remove-attachment | shortcut | base_attachment_dryrun_test.go::TestBase_AttachmentDryRun/remove | dry-run only | request shape only |
@@ -78,6 +86,8 @@
| ✓ | base +table-get | shortcut | base_basic_workflow_test.go::TestBase_BasicWorkflow/get table as bot | `--base-token`; `--table-id` | |
| ✓ | base +table-list | shortcut | base_basic_workflow_test.go::TestBase_BasicWorkflow/list tables and find created table as bot | `--base-token` | |
| ✕ | base +table-update | shortcut | | none | no rename workflow yet |
| ✕ | base +title-resolve | shortcut | | none | resolver workflow not covered |
| ✕ | base +url-resolve | shortcut | | none | resolver workflow not covered |
| ✕ | base +view-create | shortcut | | none | view workflows not covered |
| ✕ | base +view-delete | shortcut | | none | view workflows not covered |
| ✕ | base +view-get | shortcut | | none | view workflows not covered |

View File

@@ -93,6 +93,55 @@ func TestDrivePreviewDryRun_Download(t *testing.T) {
}
}
// TestDrivePreviewDryRun_SourceFile verifies source_file mode maps to a direct
// source artifact download request.
func TestDrivePreviewDryRun_SourceFile(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+preview",
"--file-token", "fileDryRunPreview",
"--type", "source_file",
"--version", "12",
"--output", "./artifacts/source",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := clie2e.DryRunGet(out, "api.#").Int(); got != 1 {
t.Fatalf("api count=%d, want 1\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method=%q, want GET\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.url").String(); got != "/open-apis/drive/v1/medias/fileDryRunPreview/preview_download" {
t.Fatalf("url=%q, want preview download endpoint\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.params.preview_type").String(); got != "16" {
t.Fatalf("preview_type=%q, want 16\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.params.version").String(); got != "12" {
t.Fatalf("version=%q, want 12\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "requested_type").String(); got != "source_file" {
t.Fatalf("requested_type=%q, want source_file\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "selected_type").String(); got != "source_file" {
t.Fatalf("selected_type=%q, want source_file\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "selected_type_code").String(); got != "16" {
t.Fatalf("selected_type_code=%q, want 16\nstdout:\n%s", got, out)
}
}
// TestDriveCoverDryRun_Download verifies cover dry-run request structure for
// download mode.
func TestDriveCoverDryRun_Download(t *testing.T) {

View File

@@ -35,6 +35,41 @@ func TestDrive_PreviewAndCoverWorkflow(t *testing.T) {
fileToken := uploadPreviewFixture(t, parentT, ctx, workDir, folderToken, sourceRelPath, "report.txt")
t.Run("source file download", func(t *testing.T) {
downloadDir := t.TempDir()
downloadResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+preview",
"--file-token", fileToken,
"--type", "source_file",
"--output", "./artifacts/report-source",
},
WorkDir: downloadDir,
DefaultAs: "bot",
})
require.NoError(t, err)
downloadResult.AssertExitCode(t, 0)
downloadResult.AssertStdoutStatus(t, true)
stdout := downloadResult.Stdout
if gjson.Get(stdout, "data.requested_type").Exists() {
t.Fatalf("requested_type should be omitted from execute output\nstdout:\n%s", stdout)
}
if got := gjson.Get(stdout, "data.selected_type").String(); got != "source_file" {
t.Fatalf("selected_type=%q, want source_file\nstdout:\n%s", got, stdout)
}
if gjson.Get(stdout, "data.selected_type_code").Exists() {
t.Fatalf("selected_type_code should be omitted from execute output\nstdout:\n%s", stdout)
}
outputPath := gjson.Get(stdout, "data.output_path").String()
require.NotEmpty(t, outputPath, "source file preview should return output_path")
data, readErr := os.ReadFile(outputPath)
require.NoError(t, readErr)
if string(data) != sourceContent {
t.Fatalf("source file preview content=%q want %q", string(data), sourceContent)
}
})
t.Run("preview list and download", func(t *testing.T) {
listResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{

View File

@@ -149,11 +149,14 @@ func TestMarkdownDiffDryRun_RemoteVsRemote(t *testing.T) {
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnMarkdownDryRun/download")
assert.Contains(t, output, `"mode": "remote_vs_remote"`)
assert.Contains(t, output, `"version": "7633658129540910621"`)
assert.Contains(t, output, `"version": "7633658129540910628"`)
assert.Contains(t, output, `"context_lines": 1`)
assert.Contains(t, output, "/open-apis/drive/v1/medias/boxcnMarkdownDryRun/preview_download")
require.Equal(t, "remote_vs_remote", clie2e.DryRunGet(output, "mode").String(), output)
require.Equal(t, int64(2), clie2e.DryRunGet(output, "api.#").Int(), output)
require.Equal(t, "16", clie2e.DryRunGet(output, "api.0.params.preview_type").String(), output)
require.Equal(t, "7633658129540910621", clie2e.DryRunGet(output, "api.0.params.version").String(), output)
require.Equal(t, "16", clie2e.DryRunGet(output, "api.1.params.preview_type").String(), output)
require.Equal(t, "7633658129540910628", clie2e.DryRunGet(output, "api.1.params.version").String(), output)
require.Equal(t, int64(1), clie2e.DryRunGet(output, "context_lines").Int(), output)
}
func TestMarkdownDiffDryRun_RemoteVsLocal(t *testing.T) {
@@ -179,8 +182,9 @@ func TestMarkdownDiffDryRun_RemoteVsLocal(t *testing.T) {
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnMarkdownDryRun/download")
assert.Contains(t, output, "/open-apis/drive/v1/medias/boxcnMarkdownDryRun/preview_download")
assert.Contains(t, output, `"mode": "remote_vs_local"`)
assert.Contains(t, output, `"preview_type": "16"`)
assert.Contains(t, output, `"local_file": "./draft.md"`)
}
@@ -224,7 +228,8 @@ func TestMarkdownFetchDryRun_OutputFile(t *testing.T) {
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnMarkdownDryRun/download")
assert.Contains(t, output, "/open-apis/drive/v1/medias/boxcnMarkdownDryRun/preview_download")
assert.Contains(t, output, `"preview_type": "16"`)
assert.Contains(t, output, `"output": "./copy.md"`)
}
@@ -305,7 +310,8 @@ func TestMarkdownPatchDryRun_Content(t *testing.T) {
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnMarkdownDryRun/download")
assert.Contains(t, output, "/open-apis/drive/v1/medias/boxcnMarkdownDryRun/preview_download")
assert.Contains(t, output, `"preview_type": "16"`)
assert.Contains(t, output, "/open-apis/drive/v1/metas/batch_query")
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_all")
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_prepare")