diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b6e73cd..5306b6b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th targets to the initial card-creation request and prepending its returned `atTag` to the automatic streaming update. +### Fixed + +- **Machine-readable export and download receipts** — `dws doc export`, + `dws drive download`, and `dws drive download --version` now keep progress + logs on stderr under `--format json` and emit one JSON result on stdout after + a successful local write. The result includes the saved path and byte size; + document exports additionally report the node, requested format, job/task + ID, and final status. ### Changed - **Minutes `permission apply --policy` type** — `--policy` is now declared as diff --git a/internal/helpers/doc.go b/internal/helpers/doc.go index c1aa8b1e..1897aa18 100644 --- a/internal/helpers/doc.go +++ b/internal/helpers/doc.go @@ -3668,6 +3668,17 @@ CLI 内部自动完成全部流程: } if deps.Caller.DryRun() { + if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") { + return deps.Out.PrintJSON(map[string]any{ + "dry_run": true, + "executed": false, + "preview_kind": "plan", + "operation": "doc_export", + "nodeId": node, + "exportFormat": format, + "savedPath": outputPath, + }) + } deps.Out.PrintKeyValue("操作", "导出文档(提交+轮询+下载)") deps.Out.PrintKeyValue("文档", node) deps.Out.PrintKeyValue("输出", outputPath) @@ -3678,7 +3689,7 @@ CLI 内部自动完成全部流程: ctx := context.Background() // ── Step 1: 提交导出任务 ── - deps.Out.PrintInfo("[1/3] 提交导出任务...") + printJSONSafeInfo("[1/3] 提交导出任务...") submitText, err := callMCPToolReturnText(ctx, "submit_export_job", submitArgs) if err != nil { return fmt.Errorf("提交导出任务失败: %w", err) @@ -3693,10 +3704,10 @@ CLI 内部自动完成全部流程: deps.Out.PrintRaw(submitText) return fmt.Errorf("提交导出任务成功但未返回 jobId") } - deps.Out.PrintInfo(fmt.Sprintf(" 任务已提交,jobId: %s", jobID)) + printJSONSafeInfo(fmt.Sprintf(" 任务已提交,jobId: %s", jobID)) // ── Step 2: 渐进式退避轮询 ── - deps.Out.PrintInfo("[2/3] 等待导出完成...") + printJSONSafeInfo("[2/3] 等待导出完成...") downloadURL, err := pollDocExportJob(ctx, jobID) if err != nil { return err @@ -3715,11 +3726,28 @@ CLI 内部自动完成全部流程: outputPath = filepath.Join(outputPath, filename) } - deps.Out.PrintInfo(fmt.Sprintf("[3/3] 下载文件到 %s ...", outputPath)) + printJSONSafeInfo(fmt.Sprintf("[3/3] 下载文件到 %s ...", outputPath)) if err := httpGetFile(ctx, downloadURL, nil, outputPath); err != nil { return fmt.Errorf("文件下载失败 (jobId=%s): %w", jobID, err) } + if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") { + info, err := os.Stat(outputPath) + if err != nil { + return fmt.Errorf("读取导出产物信息失败 (jobId=%s): %w", jobID, err) + } + return deps.Out.PrintJSON(map[string]any{ + "success": true, + "nodeId": node, + "exportFormat": format, + "jobId": jobID, + "taskId": jobID, + "status": "SUCCESS", + "savedPath": outputPath, + "sizeBytes": info.Size(), + }) + } + deps.Out.PrintInfo(fmt.Sprintf("导出完成: %s", outputPath)) return nil }, @@ -4657,7 +4685,7 @@ func pollDocExportJob(ctx context.Context, jobID string) (downloadURL string, er for attempt := 1; attempt <= maxPolls; attempt++ { interval := pollInterval(attempt) - deps.Out.PrintInfo(fmt.Sprintf(" 第 %d/%d 次查询,等待 %v ...", attempt, maxPolls, interval)) + printJSONSafeInfo(fmt.Sprintf(" 第 %d/%d 次查询,等待 %v ...", attempt, maxPolls, interval)) select { case <-ctx.Done(): diff --git a/internal/helpers/drive.go b/internal/helpers/drive.go index 83b7ba6d..752ab25d 100644 --- a/internal/helpers/drive.go +++ b/internal/helpers/drive.go @@ -619,10 +619,20 @@ func newDriveCommand() *cobra.Command { return err } dlOpts.logf = func(format string, a ...any) { - deps.Out.PrintInfo(fmt.Sprintf(format, a...)) + printJSONSafeInfo(fmt.Sprintf(format, a...)) } if deps.Caller.DryRun() { + if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") { + return deps.Out.PrintJSON(map[string]any{ + "dry_run": true, + "executed": false, + "preview_kind": "plan", + "operation": "drive_download", + "nodeId": fileID, + "savedPath": outputPath, + }) + } deps.Out.PrintKeyValue("操作", "下载钉盘文件") deps.Out.PrintKeyValue("文件ID", fileID) deps.Out.PrintKeyValue("输出", outputPath) @@ -632,7 +642,7 @@ func newDriveCommand() *cobra.Command { ctx := cmd.Context() // Step 1: 获取下载 URL 和签名请求头 - deps.Out.PrintInfo("[1/2] 获取下载链接...") + printJSONSafeInfo("[1/2] 获取下载链接...") text, err := callMCPToolReturnText(ctx, "download_file", argsMap) if err != nil { return err @@ -654,7 +664,7 @@ func newDriveCommand() *cobra.Command { } // Step 2: 分片下载(自动分派 + 401/403 凭证刷新重试) - deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath)) + printJSONSafeInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath)) dlOpts.knownSize = parseDownloadFileSize(text) dlOpts.nodeID = fileID dlOpts.version = parseDownloadFileVersion(text) @@ -684,6 +694,19 @@ func newDriveCommand() *cobra.Command { return err } + if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") { + info, err := os.Stat(outputPath) + if err != nil { + return fmt.Errorf("读取下载产物信息失败: %w", err) + } + return deps.Out.PrintJSON(map[string]any{ + "success": true, + "nodeId": fileID, + "version": dlOpts.version, + "savedPath": outputPath, + "sizeBytes": info.Size(), + }) + } deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath)) return nil }, @@ -772,10 +795,21 @@ func newDriveCommand() *cobra.Command { return err } dlOpts.logf = func(format string, a ...any) { - deps.Out.PrintInfo(fmt.Sprintf(format, a...)) + printJSONSafeInfo(fmt.Sprintf(format, a...)) } if deps.Caller.DryRun() { + if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") { + return deps.Out.PrintJSON(map[string]any{ + "dry_run": true, + "executed": false, + "preview_kind": "plan", + "operation": "drive_download_version", + "nodeId": fileID, + "version": versionNum, + "savedPath": outputPath, + }) + } deps.Out.PrintKeyValue("操作", "下载文件历史版本") deps.Out.PrintKeyValue("节点ID", fileID) deps.Out.PrintKeyValue("版本号", fmt.Sprintf("%d", versionNum)) @@ -784,7 +818,7 @@ func newDriveCommand() *cobra.Command { } ctx := cmd.Context() - deps.Out.PrintInfo("[1/2] 获取历史版本下载链接...") + printJSONSafeInfo("[1/2] 获取历史版本下载链接...") dlArgsMap := map[string]any{ "nodeId": fileID, "version": versionNum, @@ -804,7 +838,7 @@ func newDriveCommand() *cobra.Command { } outputPath = filepath.Join(outputPath, filename) } - deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath)) + printJSONSafeInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath)) dlOpts.knownSize = parseDownloadFileSize(text) dlOpts.nodeID = fileID dlOpts.version = versionNum @@ -833,6 +867,19 @@ func newDriveCommand() *cobra.Command { } return err } + if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") { + info, err := os.Stat(outputPath) + if err != nil { + return fmt.Errorf("读取下载产物信息失败: %w", err) + } + return deps.Out.PrintJSON(map[string]any{ + "success": true, + "nodeId": fileID, + "version": versionNum, + "savedPath": outputPath, + "sizeBytes": info.Size(), + }) + } deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath)) return nil }, diff --git a/internal/helpers/json_output_contract_test.go b/internal/helpers/json_output_contract_test.go new file mode 100644 index 00000000..379befde --- /dev/null +++ b/internal/helpers/json_output_contract_test.go @@ -0,0 +1,198 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helpers + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" + "github.com/spf13/cobra" +) + +func executeJSONOutputContractCommand(t *testing.T, caller *scriptedToolCaller, build func() *cobra.Command, args ...string) (string, string, error) { + t.Helper() + testseam.Protect(t, &deps) + testseam.Protect(t, &os.Args) + InitDeps(caller) + var stdout, stderr bytes.Buffer + deps.Out.w = &stdout + deps.Out.errW = &stderr + + root := build() + installExampleGlobalFlags(root) + os.Args = append([]string{"dws", root.Name()}, args...) + root.SilenceErrors = true + root.SilenceUsage = true + root.SetArgs(args) + if err := root.Execute(); err != nil { + return stdout.String(), stderr.String(), err + } + return stdout.String(), stderr.String(), nil +} + +func assertJSONOutputPayload(t *testing.T, stdout string) map[string]any { + t.Helper() + var payload map[string]any + if err := json.Unmarshal([]byte(stdout), &payload); err != nil { + t.Fatalf("stdout is not JSON: %v\n%s", err, stdout) + } + return payload +} + +func TestCrossPlatformCoverageJSONOutputContractForCompletedFileTransfers(t *testing.T) { + testseam.Swap(t, &httpGetFile, func(_ context.Context, _ string, _ map[string]string, destination string) error { + return os.WriteFile(destination, []byte("payload"), 0o600) + }) + + t.Run("drive latest download", func(t *testing.T) { + outputPath := filepath.Join(t.TempDir(), "latest.txt") + stdout, stderr, err := executeJSONOutputContractCommand(t, + &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"downloadUrl":"https://example.test/latest.txt","fileSize":7,"version":9}`}}}, + newDriveCommand, + "download", "--node", "node-latest", "--output", outputPath) + if err != nil { + t.Fatal(err) + } + payload := assertJSONOutputPayload(t, stdout) + if payload["nodeId"] != "node-latest" || payload["savedPath"] != outputPath || payload["sizeBytes"] != float64(7) || payload["version"] != float64(9) { + t.Fatalf("payload = %#v", payload) + } + if !strings.Contains(stderr, "下载完成") && !strings.Contains(stderr, "下载文件到") { + t.Fatalf("expected progress on stderr, got %q", stderr) + } + }) + + t.Run("drive historical download through compatibility flag", func(t *testing.T) { + outputPath := filepath.Join(t.TempDir(), "versioned.txt") + stdout, _, err := executeJSONOutputContractCommand(t, + &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"downloadUrl":"https://example.test/versioned.txt","fileSize":7}`}}}, + newDriveCommand, + "download", "--node", "node-versioned", "--version", "4", "--output", outputPath) + if err != nil { + t.Fatal(err) + } + payload := assertJSONOutputPayload(t, stdout) + if payload["nodeId"] != "node-versioned" || payload["version"] != float64(4) || payload["sizeBytes"] != float64(7) { + t.Fatalf("payload = %#v", payload) + } + }) + + t.Run("doc export", func(t *testing.T) { + testseam.Swap(t, &helperAfter, func(time.Duration) <-chan time.Time { + ch := make(chan time.Time, 1) + ch <- time.Now() + return ch + }) + outputPath := filepath.Join(t.TempDir(), "export.md") + stdout, stderr, err := executeJSONOutputContractCommand(t, + &scriptedToolCaller{format: "json", steps: []scriptedToolStep{ + {text: `{"jobId":"export-job-1"}`}, + {text: `{"status":"SUCCESS","downloadUrl":"https://example.test/export.md"}`}, + }}, + newDocCommand, + "export", "--node", "doc-node", "--export-format", "markdown", "--output", outputPath) + if err != nil { + t.Fatal(err) + } + payload := assertJSONOutputPayload(t, stdout) + if payload["nodeId"] != "doc-node" || payload["exportFormat"] != "markdown" || payload["jobId"] != "export-job-1" || payload["taskId"] != "export-job-1" || payload["status"] != "SUCCESS" || payload["sizeBytes"] != float64(7) { + t.Fatalf("payload = %#v", payload) + } + if !strings.Contains(stderr, "提交导出任务") { + t.Fatalf("expected export progress on stderr, got %q", stderr) + } + }) +} + +func TestCrossPlatformCoverageJSONOutputContractDryRunIsMachineReadable(t *testing.T) { + stdout, _, err := executeJSONOutputContractCommand(t, + &scriptedToolCaller{format: "json", dry: true}, + newDriveCommand, + "download", "--node", "node-dry-run", "--output", filepath.Join(t.TempDir(), "out.txt"), "--dry-run") + if err != nil { + t.Fatal(err) + } + payload := assertJSONOutputPayload(t, stdout) + if payload["dry_run"] != true || payload["executed"] != false || payload["nodeId"] != "node-dry-run" { + t.Fatalf("payload = %#v", payload) + } + + stdout, _, err = executeJSONOutputContractCommand(t, + &scriptedToolCaller{format: "json", dry: true}, + newDocCommand, + "export", "--node", "doc-dry-run", "--export-format", "markdown", "--output", filepath.Join(t.TempDir(), "export.md"), "--dry-run") + if err != nil { + t.Fatal(err) + } + payload = assertJSONOutputPayload(t, stdout) + if payload["dry_run"] != true || payload["executed"] != false || payload["nodeId"] != "doc-dry-run" || payload["operation"] != "doc_export" { + t.Fatalf("payload = %#v", payload) + } +} + +func TestCrossPlatformCoverageJSONOutputContractReportsMissingLocalArtifact(t *testing.T) { + testseam.Swap(t, &httpGetFile, func(context.Context, string, map[string]string, string) error { + return nil + }) + testseam.Swap(t, &helperAfter, func(time.Duration) <-chan time.Time { + ch := make(chan time.Time, 1) + ch <- time.Now() + return ch + }) + + tests := []struct { + name string + build func() *cobra.Command + args []string + steps []scriptedToolStep + }{ + { + name: "latest drive download", + build: newDriveCommand, + args: []string{"download", "--node", "node-latest", "--output", filepath.Join(t.TempDir(), "latest.txt")}, + steps: []scriptedToolStep{{text: `{"downloadUrl":"https://example.test/latest.txt","fileSize":7,"version":9}`}}, + }, + { + name: "versioned drive download", + build: newDriveCommand, + args: []string{"download", "--node", "node-versioned", "--version", "4", "--output", filepath.Join(t.TempDir(), "versioned.txt")}, + steps: []scriptedToolStep{{text: `{"downloadUrl":"https://example.test/versioned.txt","fileSize":7}`}}, + }, + { + name: "doc export", + build: newDocCommand, + args: []string{"export", "--node", "doc-node", "--export-format", "markdown", "--output", filepath.Join(t.TempDir(), "export.md")}, + steps: []scriptedToolStep{ + {text: `{"jobId":"export-job-1"}`}, + {text: `{"status":"SUCCESS","downloadUrl":"https://example.test/export.md"}`}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, _, err := executeJSONOutputContractCommand(t, &scriptedToolCaller{format: "json", steps: tt.steps}, tt.build, tt.args...) + if err == nil || !strings.Contains(err.Error(), "读取") { + t.Fatalf("expected missing local artifact error, got %v", err) + } + }) + } +} diff --git a/internal/helpers/output.go b/internal/helpers/output.go index 73c3307c..94313a45 100644 --- a/internal/helpers/output.go +++ b/internal/helpers/output.go @@ -179,6 +179,17 @@ func (f *Formatter) PrintInfo(msg string) { fmt.Fprintf(f.w, "[INFO] %s\n", func (f *Formatter) PrintProgress(msg string) { fmt.Fprintf(f.errW, "%s\n", msg) } func (f *Formatter) PrintDim(msg string) { fmt.Fprintf(f.w, " %s\n", msg) } +// printJSONSafeInfo keeps command progress out of stdout when the caller +// requested JSON. A successful JSON command must leave stdout parseable; its +// human-readable progress belongs on stderr instead. +func printJSONSafeInfo(msg string) { + if deps != nil && deps.Caller != nil && strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") { + deps.Out.PrintProgress("[INFO] " + msg) + return + } + deps.Out.PrintInfo(msg) +} + func (f *Formatter) PrintKeyValue(key, value string) { fmt.Fprintf(f.w, "%-16s%s\n", key+":", value) }