Merge remote-tracking branch 'upstream/main' into feat/unified-command-framework-core
This commit is contained in:
commit
2989c1db37
5 files changed
+303
-11
No files matched your search
@@ -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
|
||||
|
||||
+33
-5
@@ -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():
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user