Files
larksuite__cli/shortcuts/drive/drive_export.go
xiongyuanwen-byted decc9549b5 refactor(sheets): keep the success path off stderr (#2533)
* feat(sheets): reject local-office tokens in +workbook-export

A locally opened Office file (a local_office_ / fake_office_ token, or an
interleaved OFL0X one) names a file the Lark client is showing, not a cloud
document, so the drive export task can only fail on the backend -- and it
fails late, after the create and poll round trips, with an opaque message.

Refuse it up front with a typed failed_precondition that says the workbook is
already a file on disk, and points at +workbook-import for callers who want a
cloud spreadsheet they can export later. The check runs in Validate (so
--dry-run is covered too) and again after the wiki hop in Execute, where the
real spreadsheet token is first known.

* refactor(sheets): report success-path advisories in the result, not on stderr

Every sheets shortcut that had something to say on a successful run said it on
stderr: ignored sub-op locators, emulated dimension semantics, the deprecated
--dimension/--count and +cells-batch-set-style spellings, the dropdown
option-error steer, and the upload/export stage lines in the compatibility
layer. PowerShell's native-command handling and most agent harnesses read
non-empty stderr as failure, so a working call reported itself as an error --
and the facts a caller actually needed sat outside the JSON they parse.

Pure stage text ("Writing image", "Waiting for export task") is deleted: it
duplicates what the result already proves. Everything decision-relevant moves
into the payload:

  - data.warnings           ignored locators, colliding freezes, the dropdown
                            option-error steer (also shown in --dry-run now)
  - data.effective_operation +dim-insert's anchor shift under --inherit-style
                            before, and the whole (rows, cols) state a freeze
                            leaves behind
  - data.deprecation        +cells-batch-set-style and +dim-freeze's legacy
                            flag pair, under a key of its own rather than
                            mixed into warnings
  - data.upload             how +media-upload sent the file

Clean calls keep their exact previous payload shape: every field above is
added only when it has something to report.

Scope is shortcuts/sheets/** on purpose. The remaining success-path stderr in
this domain comes from shared code (the drive export/import core behind
+workbook-export / +workbook-import, the multipart media helper, the auto-grant
helper), which other domains share; cleaning those up belongs to their own
change. The one sheets-owned exception is +workbook-import's extension
correction, which has no slot in the import core's output envelope -- it is
documented at the call site and allowlisted in the guard test.

Tests pin the contract (a successful run leaves stderr empty) and each new
field, plus a source scan that stops new direct ErrOut writes from appearing.

* docs(sheets): point the dropdown option-error warning at data.warnings

The --source-range flag help still told callers the option-error steer arrives
on stderr; it now rides in the result. Mirrors the same edit in the upstream
spec (canonical-spec/spec-tables/flags.json), so the next sync is a no-op.

* fix(sheets): keep export identifiers in +export output, tighten the stderr guard

Review follow-ups on the success-path stderr change:

- +export --output-path lost file_token: on the download branch the token
  reached the caller only through the deleted "Export complete: file_token=…"
  stderr line, and the payload carried just saved_path and size_bytes. Both
  file_token and ticket now ride in the download result, so a caller can
  re-download or resume without re-running the export.

- The stderr guard allowlisted a whole file, hiding any future write in it.
  It now matches one exact statement in one file and asserts that write still
  exists, so both a new write and a stale exception fail the test.

- The contract comments claimed more than the tests prove. They now state
  that only sheets-OWNED code is silent, name the three commands whose noise
  comes from shared implementations (+workbook-export, +workbook-import,
  +media-upload over 20MB), and a new test pins that the shared export core
  does still write -- failing, by design, once that core is cleaned up.

* fix(drive): keep the export and import cores off stderr on success

+workbook-export and +workbook-import delegate to drive.RunExport /
drive.RunImport, so the sheets success-path contract could not hold while
those cores narrated every step: task creation, each poll attempt, completion,
"still in progress", and the import's media upload. Callers that read
non-empty stderr as failure saw a finished export report itself as an error.

The stage text is deleted -- ticket, ready, status, file_token, token and
next_command are all already in the payload. What the narration alone carried
moves into the result:

  - poll               attempts / transient_failures / last_error, added only
                       when a poll actually had to be retried, so a caller can
                       tell a clean run from one that limped to the finish
  - warnings           markdown export falling back to the token as file name
                       after a failed title lookup
  - input_corrections  a caller-supplied record of inputs the CLI rewrote
                       before the request ran; sheets +workbook-import uses it
                       for a mislabeled .xls that is really an .xlsx, which was
                       its last stderr write

drive +export / +import get the same treatment, since they share these cores.
Clean runs keep their exact previous payload shape.

With this, the sheets stderr guard needs no allowlist, and the contract test
covers both workbook commands end to end. Two shared paths a sheets caller can
still reach stay noisy and are named in the contract comment: multipart media
upload over 20MB, and the bot-identity auto-grant warning.

* test(sheets): cover the annotation shapes and both guard call sites

Review follow-ups, all test-side except one comment:

- +dim-insert's effective_operation had no test: a regression could drop the
  emulated-anchor block and still keep stderr empty. Now asserted field by
  field, plus the negative case (--inherit-style after rewrites nothing, so it
  must not gain the block).

- The local-office guard's second call site had no test. A /wiki/ URL only
  reveals its backing token after get_node runs in Execute, so that branch is
  now covered, asserting both the typed rejection and that no export task was
  created.

- annotateSheetsResult's three payload shapes are pinned: object annotated in
  place, array/scalar preserved under `result`, and an empty tool result left
  without an invented `result: null`. The doc comment now spells out that last
  case instead of lumping it in with non-object output.

- The export poll summary test asserted transient_failures but not attempts,
  so a wrong or missing count would have passed.

* fix: preserve recovery state on failure paths and TTY liveness during polls

Review round 2. Removing the success-path narration also removed information
from paths that fail after remote work has started, and removed the only
liveness signal an interactive user had:

- drive +import / sheets +workbook-import: once the import task exists, the
  ticket is the only handle back to it. A poll failure returned bare, so the
  ticket -- previously visible through the polling line -- was lost. It now
  rides on the typed error together with the +task_result command.

- sheets +export --output-path: a download or save failure happens after the
  artifact is ready, so the error now carries ticket, file_token and the
  +export-download command; re-running the whole export is not the recovery.
  A poll timeout carries the ticket for the same reason.

- sheets +batch-update / +batch-chart-*: batch_update is fail-fast without
  rollback, so the ignored-locator and colliding-freeze advisories matter most
  exactly when the call fails part-way -- they decide the safe retry set. They
  are now attached to the typed error's hint as well as the success payload.

- Bounded polls and the import upload are wrapped in RuntimeContext.StartSpinner,
  which is gated on StderrIsTerminal and is a strict no-op for pipes, CI and
  captured output. A human terminal gets liveness back; a machine caller's
  stderr stays empty (the contract tests, which capture stderr, still pass).

+workbook-export's rejection of Office tokens also stopped assuming the caller
holds the file: a local_office_ / fake_office_ prefix means the workbook is
already on their disk, but an interleaved OFL0X token is a file stored in Lark
that may never have been downloaded, so that class is now pointed at
drive +download (then +workbook-import if they want a Lark spreadsheet).

Each behaviour above has a regression test; httpmock's CapturedBodies doc
comment is corrected, since it is appended on every match, not only for
Reusable stubs.
2026-08-28 12:48:19 +08:00

461 lines
17 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// wrapExportContextErr converts a context cancellation / deadline error into a
// typed errs.NetworkError so the cobra layer sees a typed envelope (with cause
// preserved for errors.Is) instead of an untyped context.Canceled /
// context.DeadlineExceeded escaping as a plain string. CR-flagged hole on the
// poll loop: returning ctx.Err() directly bypassed the typed-error contract.
// command names the shortcut actually running — RunExport is shared between
// drive +export and sheets +workbook-export, and a hard-coded "drive +export"
// here handed +workbook-export users a recovery command they never ran.
func wrapExportContextErr(command string, err error) error {
if err == nil {
return nil
}
subtype := errs.SubtypeNetworkTransport
verb := "cancelled"
if errors.Is(err, context.DeadlineExceeded) {
subtype = errs.SubtypeNetworkTimeout
verb = "deadline exceeded"
}
return errs.NewNetworkError(subtype, "%s polling %s: %s", command, verb, err).WithCause(err)
}
// DriveExport exports Drive-native documents to local files and falls back to
// a follow-up command when the async export task does not finish in time.
var DriveExport = common.Shortcut{
Service: "drive",
Command: "+export",
Description: "Export a doc/docx/sheet/bitable/slides or wiki document to a local file with limited polling",
Risk: "read",
Scopes: []string{
"docs:document.content:read",
"docs:document:export",
"docx:document:readonly",
"drive:drive.metadata:readonly",
},
ConditionalScopes: []string{"wiki:node:retrieve"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "source document URL; doc type and token are inferred, and wiki URLs are resolved to the underlying document"},
{Name: "token", Desc: "source document token; bare tokens require --doc-type, and wiki tokens should use --doc-type wiki"},
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable | slides | wiki (required only when --token is a bare token)", Enum: []string{"doc", "docx", "sheet", "bitable", "slides", "wiki"}},
{Name: "file-extension", Desc: "export format: docx | pdf | xlsx | csv | markdown | base (bitable only) | pptx (slides only)", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx"}},
{Name: "sub-id", Desc: "sub-table/sheet ID, required when exporting sheet/bitable as csv"},
{Name: "only-schema", Type: "bool", Desc: "export only bitable schema when --doc-type bitable --file-extension base"},
{Name: "file-name", Desc: "preferred output filename (optional)"},
{Name: "output-dir", Default: ".", Desc: "local output directory (default: current directory)"},
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateExport(exportParamsFromFlags(runtime))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return PlanExportDryRun(runtime, exportParamsFromFlags(runtime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return RunExport(ctx, runtime, exportParamsFromFlags(runtime))
},
}
// ExportParams holds the user-facing inputs for an export flow, decoupled from
// cobra flags so other command groups (e.g. sheets +workbook-export) can reuse
// the drive export implementation. An empty OutputDir means "create the export
// task and poll, but do not download" — callers that only need the ready file
// token / status get it back without writing a local file.
type ExportParams struct {
URL string
Token string
DocType string
FileExtension string
SubID string
OnlySchema bool
OutputDir string
FileName string
Overwrite bool
}
func (p ExportParams) spec() driveExportSpec {
return driveExportSpec{
URL: p.URL,
Token: p.Token,
DocType: p.DocType,
FileExtension: p.FileExtension,
SubID: p.SubID,
OnlySchema: p.OnlySchema,
}
}
// exportParamsFromFlags reads the standard drive +export flag set.
func exportParamsFromFlags(runtime *common.RuntimeContext) ExportParams {
// drive +export always downloads; an empty --output-dir historically means
// the current directory (saveContentToOutputDir maps "" -> "."), so normalize
// it here to keep behavior identical and stay off the export-only ("" => skip
// download) path that only sheets +workbook-export uses.
outputDir := runtime.Str("output-dir")
if outputDir == "" {
outputDir = "."
}
return ExportParams{
URL: runtime.Str("url"),
Token: runtime.Str("token"),
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
OnlySchema: runtime.Bool("only-schema"),
OutputDir: outputDir,
FileName: strings.TrimSpace(runtime.Str("file-name")),
Overwrite: runtime.Bool("overwrite"),
}
}
// validateExport runs the CLI-level export constraint checks. Unexported because
// only drive +export's Validate consumes it directly; sheets +workbook-export
// reuses RunExport / PlanExportDryRun but inlines its own (sheet-specific)
// validation, so there is no cross-package call site to keep exported.
func validateExport(p ExportParams) error {
return validateDriveExportSpec(p.spec())
}
// PlanExportDryRun builds the dry-run plan for an export without performing I/O.
func PlanExportDryRun(runtime *common.RuntimeContext, p ExportParams) *common.DryRunAPI {
spec, source, err := normalizeDriveExportSpecInput(p.spec())
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
if err := validateDriveExportNormalizedSpecForSource(spec, source); err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
dry := common.NewDryRunAPI()
if source.Type == "wiki" {
dry.GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[0] Resolve wiki node to underlying document token").
Params(map[string]interface{}{"token": source.Token})
spec.Token = "obj_token_from_step_0"
if spec.DocType == "" {
spec.DocType = "obj_type_from_step_0"
}
dry.Set("wiki_token", source.Token)
}
// Markdown export is a special case: docx markdown comes from the V2
// docs_ai fetch API directly instead of the Drive export task API.
if spec.FileExtension == "markdown" {
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
desc := "2-step orchestration: fetch docx markdown -> write local file"
if source.Type == "wiki" {
desc = "3-step orchestration: resolve wiki -> fetch docx markdown -> write local file"
}
dry.Desc(desc).
POST(apiPath).
Body(map[string]interface{}{
"format": "markdown",
}).
Set("output_dir", p.OutputDir)
if name := strings.TrimSpace(p.FileName); name != "" {
dry.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
}
return dry
}
desc := "3-step orchestration: create export task -> limited polling -> download file"
if source.Type == "wiki" {
desc = "4-step orchestration: resolve wiki -> create export task -> limited polling -> download file"
}
dry.Desc(desc).
POST("/open-apis/drive/v1/export_tasks").
Body(buildDriveExportTaskBody(spec)).
Set("output_dir", p.OutputDir)
if name := strings.TrimSpace(p.FileName); name != "" {
dry.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
}
return dry
}
// RunExport drives create export task -> bounded poll -> optional download. It
// is the shared core behind both drive +export and sheets +workbook-export. An
// empty p.OutputDir skips the download step and returns the ready file token.
func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportParams) error {
spec, source, err := normalizeDriveExportSpecInput(p.spec())
if err != nil {
return err
}
if err := validateDriveExportNormalizedSpecForSource(spec, source); err != nil {
return err
}
outputDir := p.OutputDir
preferredFileName := strings.TrimSpace(p.FileName)
overwrite := p.Overwrite
var wikiResolution driveExportWikiResolution
// Markdown export bypasses the async export task and writes the fetched
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
// higher-quality Lark-flavored Markdown output.
if spec.FileExtension == "markdown" {
if source.Type == "wiki" {
resolvedSpec, resolution, err := resolveDriveExportWikiSource(ctx, runtime, spec, source.Token)
if err != nil {
return err
}
spec = resolvedSpec
wikiResolution = resolution
}
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
data, err := runtime.CallAPITyped(
"POST",
apiPath,
nil,
map[string]interface{}{
"format": "markdown",
},
)
if err != nil {
return err
}
// Extract content from the V2 response: data.document.content
doc, ok := data["document"].(map[string]interface{})
if !ok {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
}
content, ok := doc["content"].(string)
if !ok {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
}
var warnings []string
fileName := preferredFileName
if fileName == "" {
// Prefer the remote title for the exported file name, but still fall
// back to the token if metadata is empty.
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
if err != nil {
// A degraded file name changes what the caller finds on disk, so
// it travels with the result instead of on stderr, which must
// stay empty on a successful run.
warnings = append(warnings, fmt.Sprintf("title lookup failed (%v); used the token as the file name", err))
title = spec.Token
}
fileName = title
}
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
if err != nil {
return err
}
markdownOut := map[string]interface{}{
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"file_name": filepath.Base(savedPath),
"saved_path": savedPath,
"size_bytes": len(content),
}
if len(warnings) > 0 {
markdownOut["warnings"] = warnings
}
runtime.Out(annotateDriveExportWikiOutput(markdownOut, wikiResolution), nil)
return nil
}
ticket, resolvedSpec, resolution, err := createDriveExportTaskResolvingWiki(ctx, runtime, spec, source)
if err != nil {
return err
}
spec = resolvedSpec
wikiResolution = resolution
// Interactive liveness only: StartSpinner is gated on StderrIsTerminal and
// is a strict no-op for pipes, CI and captured output, so the bounded poll
// window stops looking like a hang at a human terminal without putting a
// byte on a machine caller's stderr. stop() is idempotent and is called
// before every result write below so the cleared line never interleaves.
stopSpinner := runtime.StartSpinner("Exporting")
defer stopSpinner()
var lastStatus driveExportStatus
var lastPollErr error
hasObservedStatus := false
// Transient poll failures are swallowed and retried, so without a count in
// the result a caller cannot tell a clean export from one that limped to
// the finish line. That, not the per-attempt narration this replaces, is
// the part worth reporting.
pollAttempts := 0
pollFailures := 0
// Keep the command responsive by polling for a bounded window. If the task
// is still running after that, return a resume command instead of blocking.
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
if attempt > 1 {
select {
case <-ctx.Done():
return wrapExportContextErr(runtime.Command(), ctx.Err())
case <-time.After(driveExportPollInterval):
}
}
if err := ctx.Err(); err != nil {
return wrapExportContextErr(runtime.Command(), err)
}
pollAttempts = attempt
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
if err != nil {
if driveExportIsRateLimit(err) {
return withDriveExportRateLimitRecovery(err, ticket, spec.Token)
}
// Treat polling failures as transient so short-lived backend hiccups
// do not immediately fail an otherwise healthy export task.
lastPollErr = err
pollFailures++
continue
}
lastStatus = status
hasObservedStatus = true
if status.Ready() {
// Export-only mode: caller wants the ready file token / metadata but
// no local download (e.g. sheets +workbook-export without an output
// path). Skip the download and return the status envelope.
if strings.TrimSpace(outputDir) == "" {
readyOut := map[string]interface{}{
"ticket": ticket,
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"file_token": status.FileToken,
"file_name": status.FileName,
"file_size": status.FileSize,
"ready": true,
"downloaded": false,
}
attachDriveExportPollSummary(readyOut, pollAttempts, pollFailures, lastPollErr)
stopSpinner()
runtime.Out(annotateDriveExportWikiOutput(readyOut, wikiResolution), nil)
return nil
}
stopSpinner()
fileName := preferredFileName
if fileName == "" {
fileName = status.FileName
}
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
if err != nil {
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
hint := fmt.Sprintf(
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
ticket,
status.FileToken,
recoveryCommand,
)
return appendDriveExportRecoveryHint(err, hint)
}
out["ticket"] = ticket
out["doc_type"] = spec.DocType
out["file_extension"] = spec.FileExtension
attachDriveExportPollSummary(out, pollAttempts, pollFailures, lastPollErr)
runtime.Out(annotateDriveExportWikiOutput(out, wikiResolution), nil)
return nil
}
if status.Failed() {
stopSpinner()
msg := strings.TrimSpace(status.JobErrorMsg)
if msg == "" {
msg = status.StatusLabel()
}
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
}
}
stopSpinner()
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
if !hasObservedStatus && lastPollErr != nil {
hint := fmt.Sprintf(
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
ticket,
nextCommand,
)
return appendDriveExportRecoveryHint(lastPollErr, hint)
}
failed := false
var jobStatus interface{}
jobStatusLabel := "unknown"
if hasObservedStatus {
failed = lastStatus.Failed()
jobStatus = lastStatus.JobStatus
jobStatusLabel = lastStatus.StatusLabel()
}
// Return the last observed status so callers can resume from a known task
// state instead of losing all progress information on timeout.
result := map[string]interface{}{
"ticket": ticket,
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"ready": false,
"failed": failed,
"job_status": jobStatus,
"job_status_label": jobStatusLabel,
"timed_out": true,
"next_command": nextCommand,
}
if preferredFileName != "" {
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
}
attachDriveExportPollSummary(result, pollAttempts, pollFailures, lastPollErr)
// next_command in the payload is the whole resume story; the stderr copy it
// used to carry told a caller nothing extra.
runtime.Out(annotateDriveExportWikiOutput(result, wikiResolution), nil)
return nil
}
// attachDriveExportPollSummary records a poll run that needed retries. Clean
// runs add nothing, so the common payload keeps its existing shape.
func attachDriveExportPollSummary(out map[string]interface{}, attempts, failures int, lastErr error) {
if failures == 0 {
return
}
summary := map[string]interface{}{
"attempts": attempts,
"transient_failures": failures,
}
if lastErr != nil {
summary["last_error"] = lastErr.Error()
}
out["poll"] = summary
}
func annotateDriveExportWikiOutput(out map[string]interface{}, resolution driveExportWikiResolution) map[string]interface{} {
if !resolution.Resolved {
return out
}
out["wiki_token"] = resolution.WikiToken
out["wiki_node"] = map[string]interface{}{
"obj_token": resolution.ObjToken,
"obj_type": resolution.ObjType,
}
return out
}