diff --git a/shortcuts/common/resource_url.go b/shortcuts/common/resource_url.go
new file mode 100644
index 00000000..99b81bd1
--- /dev/null
+++ b/shortcuts/common/resource_url.go
@@ -0,0 +1,57 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package common
+
+import (
+ "strings"
+
+ "github.com/larksuite/cli/internal/core"
+)
+
+// BuildResourceURL returns a brand-standard, user-facing URL for a freshly
+// created Lark resource. It is intended as a fallback when the create API does
+// not return a URL field (e.g. drive +upload, wiki +node-create) or when the
+// returned URL is empty (e.g. degraded MCP responses for docs +create v1).
+//
+// The returned URL points at the brand's standard host (www.feishu.cn /
+// www.larksuite.com), which transparently redirects to the tenant-specific
+// domain. It is NOT a guess at the tenant's vanity domain.
+//
+// Returns "" when token is empty or kind is unrecognized — callers should
+// only set the field when the result is non-empty so that "" never overrides
+// a real URL the backend already returned.
+func BuildResourceURL(brand core.LarkBrand, kind, token string) string {
+ token = strings.TrimSpace(token)
+ if token == "" {
+ return ""
+ }
+
+ host := "https://www.feishu.cn"
+ if brand == core.BrandLark {
+ host = "https://www.larksuite.com"
+ }
+
+ switch strings.ToLower(strings.TrimSpace(kind)) {
+ case "docx":
+ return host + "/docx/" + token
+ case "doc":
+ return host + "/doc/" + token
+ case "sheet":
+ return host + "/sheets/" + token
+ case "bitable":
+ return host + "/base/" + token
+ case "wiki":
+ return host + "/wiki/" + token
+ case "file":
+ return host + "/file/" + token
+ case "folder":
+ return host + "/drive/folder/" + token
+ case "mindnote":
+ return host + "/mindnote/" + token
+ case "slides":
+ return host + "/slides/" + token
+ default:
+ return ""
+ }
+}
diff --git a/shortcuts/common/resource_url_test.go b/shortcuts/common/resource_url_test.go
new file mode 100644
index 00000000..9ef0d9db
--- /dev/null
+++ b/shortcuts/common/resource_url_test.go
@@ -0,0 +1,50 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package common
+
+import (
+ "testing"
+
+ "github.com/larksuite/cli/internal/core"
+)
+
+func TestBuildResourceURL(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ brand core.LarkBrand
+ kind string
+ token string
+ want string
+ }{
+ {"feishu docx", core.BrandFeishu, "docx", "doxcnABC", "https://www.feishu.cn/docx/doxcnABC"},
+ {"feishu doc legacy", core.BrandFeishu, "doc", "doccnABC", "https://www.feishu.cn/doc/doccnABC"},
+ {"feishu sheet", core.BrandFeishu, "sheet", "shtcnABC", "https://www.feishu.cn/sheets/shtcnABC"},
+ {"feishu bitable", core.BrandFeishu, "bitable", "bascnABC", "https://www.feishu.cn/base/bascnABC"},
+ {"feishu wiki", core.BrandFeishu, "wiki", "wikcnABC", "https://www.feishu.cn/wiki/wikcnABC"},
+ {"feishu file", core.BrandFeishu, "file", "boxcnABC", "https://www.feishu.cn/file/boxcnABC"},
+ {"feishu folder", core.BrandFeishu, "folder", "fldcnABC", "https://www.feishu.cn/drive/folder/fldcnABC"},
+ {"feishu mindnote", core.BrandFeishu, "mindnote", "mncnABC", "https://www.feishu.cn/mindnote/mncnABC"},
+ {"feishu slides", core.BrandFeishu, "slides", "slkcnABC", "https://www.feishu.cn/slides/slkcnABC"},
+ {"lark docx", core.BrandLark, "docx", "doxcnABC", "https://www.larksuite.com/docx/doxcnABC"},
+ {"lark wiki", core.BrandLark, "wiki", "wikcnABC", "https://www.larksuite.com/wiki/wikcnABC"},
+ {"empty brand defaults to feishu", core.LarkBrand(""), "docx", "doxcnABC", "https://www.feishu.cn/docx/doxcnABC"},
+ {"kind case-insensitive", core.BrandFeishu, "DOCX", "doxcnABC", "https://www.feishu.cn/docx/doxcnABC"},
+ {"token whitespace trimmed", core.BrandFeishu, "docx", " doxcnABC ", "https://www.feishu.cn/docx/doxcnABC"},
+ {"empty token", core.BrandFeishu, "docx", "", ""},
+ {"whitespace-only token", core.BrandFeishu, "docx", " ", ""},
+ {"unknown kind", core.BrandFeishu, "calendar", "calABC", ""},
+ {"empty kind", core.BrandFeishu, "", "doxcnABC", ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := BuildResourceURL(tt.brand, tt.kind, tt.token)
+ if got != tt.want {
+ t.Errorf("BuildResourceURL(%q, %q, %q) = %q, want %q", tt.brand, tt.kind, tt.token, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/shortcuts/doc/docs_create.go b/shortcuts/doc/docs_create.go
index 7ce75026..b0067dee 100644
--- a/shortcuts/doc/docs_create.go
+++ b/shortcuts/doc/docs_create.go
@@ -150,6 +150,24 @@ func augmentCreateResultV1(runtime *common.RuntimeContext, result map[string]int
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, target.Token, target.Type); grant != nil {
result["permission_grant"] = grant
}
+ fallbackDocURLV1(runtime, result)
+}
+
+// fallbackDocURLV1 fills result.doc_url with a brand-standard URL when the MCP
+// response did not include one but did include a doc_id. This protects against
+// degraded MCP responses (multi-content, non-JSON text) where ExtractMCPResult
+// drops structured fields.
+func fallbackDocURLV1(runtime *common.RuntimeContext, result map[string]interface{}) {
+ if strings.TrimSpace(common.GetString(result, "doc_url")) != "" {
+ return
+ }
+ docID := strings.TrimSpace(common.GetString(result, "doc_id"))
+ if docID == "" {
+ return
+ }
+ if u := common.BuildResourceURL(runtime.Config.Brand, "docx", docID); u != "" {
+ result["doc_url"] = u
+ }
}
func selectPermissionTarget(result map[string]interface{}) docsPermissionTarget {
diff --git a/shortcuts/doc/docs_create_test.go b/shortcuts/doc/docs_create_test.go
index 404b060b..746347d3 100644
--- a/shortcuts/doc/docs_create_test.go
+++ b/shortcuts/doc/docs_create_test.go
@@ -182,6 +182,67 @@ func TestDocsCreateV2BotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
}
}
+func TestDocsCreateV2FallbackURLWhenBackendOmitsIt(t *testing.T) {
+ t.Parallel()
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
+ registerDocsCreateAPIStub(reg, map[string]interface{}{
+ "document": map[string]interface{}{
+ "document_id": "doxcn_new_doc",
+ "revision_id": float64(1),
+ // "url" deliberately omitted to exercise the fallback.
+ },
+ })
+
+ err := runDocsCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--api-version", "v2",
+ "--content", "
内容正文
",
+ "--as", "user",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ data := decodeDocsCreateEnvelope(t, stdout)
+ doc, _ := data["document"].(map[string]interface{})
+ if doc == nil {
+ t.Fatalf("missing document in envelope: %#v", data)
+ }
+ if got, want := doc["url"], "https://www.feishu.cn/docx/doxcn_new_doc"; got != want {
+ t.Fatalf("document.url = %#v, want %q (brand-standard fallback)", got, want)
+ }
+}
+
+func TestDocsCreateV2PreservesBackendURL(t *testing.T) {
+ t.Parallel()
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
+ registerDocsCreateAPIStub(reg, map[string]interface{}{
+ "document": map[string]interface{}{
+ "document_id": "doxcn_new_doc",
+ "revision_id": float64(1),
+ "url": "https://tenant.larkoffice.com/docx/doxcn_new_doc",
+ },
+ })
+
+ err := runDocsCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--api-version", "v2",
+ "--content", "内容正文
",
+ "--as", "user",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ data := decodeDocsCreateEnvelope(t, stdout)
+ doc, _ := data["document"].(map[string]interface{})
+ if got, want := doc["url"], "https://tenant.larkoffice.com/docx/doxcn_new_doc"; got != want {
+ t.Fatalf("document.url = %#v, want backend tenant URL %q (fallback must not overwrite)", got, want)
+ }
+}
+
// ── V1 (MCP) tests ──
func TestDocsCreateV1BotAutoGrantSuccess(t *testing.T) {
@@ -273,6 +334,60 @@ func TestDocsCreateV1WikiSpaceAutoGrantFailure(t *testing.T) {
}
}
+func TestDocsCreateV1FallbackURLWhenBackendOmitsIt(t *testing.T) {
+ t.Parallel()
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
+ registerDocsCreateMCPStub(reg, map[string]interface{}{
+ "doc_id": "doxcn_new_doc",
+ "message": "文档创建成功",
+ // "doc_url" deliberately omitted to exercise the fallback.
+ })
+
+ err := runDocsCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--api-version", "v1",
+ "--title", "项目计划",
+ "--markdown", "## 目标",
+ "--as", "user",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ data := decodeDocsCreateEnvelope(t, stdout)
+ if got, want := data["doc_url"], "https://www.feishu.cn/docx/doxcn_new_doc"; got != want {
+ t.Fatalf("doc_url = %#v, want %q (brand-standard fallback)", got, want)
+ }
+}
+
+func TestDocsCreateV1PreservesBackendDocURL(t *testing.T) {
+ t.Parallel()
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
+ registerDocsCreateMCPStub(reg, map[string]interface{}{
+ "doc_id": "doxcn_new_doc",
+ "doc_url": "https://tenant.feishu.cn/docx/doxcn_new_doc",
+ "message": "文档创建成功",
+ })
+
+ err := runDocsCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--api-version", "v1",
+ "--title", "项目计划",
+ "--markdown", "## 目标",
+ "--as", "user",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ data := decodeDocsCreateEnvelope(t, stdout)
+ if got, want := data["doc_url"], "https://tenant.feishu.cn/docx/doxcn_new_doc"; got != want {
+ t.Fatalf("doc_url = %#v, want backend tenant URL %q (fallback must not overwrite)", got, want)
+ }
+}
+
// ── Helpers ──
func docsCreateTestConfig(t *testing.T, userOpenID string) *core.CliConfig {
diff --git a/shortcuts/doc/docs_create_v2.go b/shortcuts/doc/docs_create_v2.go
index 7aeb3655..413c4cbf 100644
--- a/shortcuts/doc/docs_create_v2.go
+++ b/shortcuts/doc/docs_create_v2.go
@@ -51,6 +51,7 @@ func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
}
augmentDocsCreatePermission(runtime, data)
+ fallbackDocsCreateURLV2(runtime, data)
runtime.OutRaw(data, nil)
return nil
}
@@ -84,3 +85,23 @@ func augmentDocsCreatePermission(runtime *common.RuntimeContext, data map[string
data["permission_grant"] = grant
}
}
+
+// fallbackDocsCreateURLV2 fills data.document.url with a brand-standard URL
+// when the OpenAPI response did not include one. Backfills only when missing,
+// so any tenant-specific URL the backend returned is preserved.
+func fallbackDocsCreateURLV2(runtime *common.RuntimeContext, data map[string]interface{}) {
+ doc, _ := data["document"].(map[string]interface{})
+ if doc == nil {
+ return
+ }
+ if strings.TrimSpace(common.GetString(doc, "url")) != "" {
+ return
+ }
+ docID := strings.TrimSpace(common.GetString(doc, "document_id"))
+ if docID == "" {
+ return
+ }
+ if u := common.BuildResourceURL(runtime.Config.Brand, "docx", docID); u != "" {
+ doc["url"] = u
+ }
+}
diff --git a/shortcuts/drive/drive_create_folder.go b/shortcuts/drive/drive_create_folder.go
index 116f147b..75c50aee 100644
--- a/shortcuts/drive/drive_create_folder.go
+++ b/shortcuts/drive/drive_create_folder.go
@@ -92,8 +92,10 @@ var DriveCreateFolder = common.Shortcut{
"folder_token": folderToken,
"parent_folder_token": spec.FolderToken,
}
- if url := common.GetString(data, "url"); url != "" {
+ if url := strings.TrimSpace(common.GetString(data, "url")); url != "" {
out["url"] = url
+ } else if u := common.BuildResourceURL(runtime.Config.Brand, "folder", folderToken); u != "" {
+ out["url"] = u
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, folderToken, "folder"); grant != nil {
out["permission_grant"] = grant
diff --git a/shortcuts/drive/drive_create_folder_test.go b/shortcuts/drive/drive_create_folder_test.go
index 2a531bb1..5d0e8980 100644
--- a/shortcuts/drive/drive_create_folder_test.go
+++ b/shortcuts/drive/drive_create_folder_test.go
@@ -232,6 +232,72 @@ func TestDriveCreateFolderUsesRootWhenParentIsOmitted(t *testing.T) {
}
}
+func TestDriveCreateFolderFallbackURLWhenBackendOmitsIt(t *testing.T) {
+ t.Parallel()
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, ""))
+
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/drive/v1/files/create_folder",
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "ok",
+ "data": map[string]interface{}{
+ "token": "fld_created",
+ // "url" deliberately omitted to exercise the fallback.
+ },
+ },
+ })
+
+ err := mountAndRunDrive(t, DriveCreateFolder, []string{
+ "+create-folder",
+ "--name", "Weekly Reports",
+ "--as", "user",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ data := decodeDriveEnvelope(t, stdout)
+ if got, want := data["url"], "https://www.feishu.cn/drive/folder/fld_created"; got != want {
+ t.Fatalf("url = %#v, want %q (brand-standard fallback)", got, want)
+ }
+}
+
+func TestDriveCreateFolderFallbackURLWhenBackendURLIsWhitespace(t *testing.T) {
+ t.Parallel()
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, ""))
+
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/drive/v1/files/create_folder",
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "ok",
+ "data": map[string]interface{}{
+ "token": "fld_created",
+ "url": " ", // whitespace-only must trigger fallback, not pass through.
+ },
+ },
+ })
+
+ err := mountAndRunDrive(t, DriveCreateFolder, []string{
+ "+create-folder",
+ "--name", "Weekly Reports",
+ "--as", "user",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ data := decodeDriveEnvelope(t, stdout)
+ if got, want := data["url"], "https://www.feishu.cn/drive/folder/fld_created"; got != want {
+ t.Fatalf("url = %#v, want %q (whitespace-only backend URL must yield fallback)", got, want)
+ }
+}
+
func TestDriveCreateFolderRejectsCreateResponseWithoutToken(t *testing.T) {
t.Parallel()
diff --git a/shortcuts/drive/drive_import.go b/shortcuts/drive/drive_import.go
index 7dbe7fd8..1b04fe7c 100644
--- a/shortcuts/drive/drive_import.go
+++ b/shortcuts/drive/drive_import.go
@@ -119,8 +119,12 @@ var DriveImport = common.Shortcut{
if status.Token != "" {
out["token"] = status.Token
}
- if status.URL != "" {
- out["url"] = status.URL
+ if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
+ out["url"] = statusURL
+ } else if status.Token != "" {
+ if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
+ out["url"] = u
+ }
}
if status.JobErrorMsg != "" {
out["job_error_msg"] = status.JobErrorMsg
@@ -205,6 +209,20 @@ func appendDriveImportUploadDryRun(dry *common.DryRunAPI, spec driveImportSpec,
})
}
+// normalizeDriveImportKindForURL maps the server's import "type" field to a
+// canonical kind BuildResourceURL recognizes. status.DocType comes straight
+// from the API and isn't normalized; if it ever returns aliases like "sheets"
+// or "sheet_v2" the URL construction would silently fall through. Fall back
+// to the user-supplied --type, which is already validated to docx/sheet/
+// bitable, so out.url stays populated whenever status.Token is set.
+func normalizeDriveImportKindForURL(serverType, fallback string) string {
+ switch strings.ToLower(strings.TrimSpace(serverType)) {
+ case "docx", "sheet", "bitable":
+ return strings.ToLower(strings.TrimSpace(serverType))
+ }
+ return fallback
+}
+
// importTargetFileName returns the explicit import name when present, otherwise
// derives one from the local file name.
func importTargetFileName(filePath, explicitName string) string {
diff --git a/shortcuts/drive/drive_import_test.go b/shortcuts/drive/drive_import_test.go
index ca72c04a..1f8d1f70 100644
--- a/shortcuts/drive/drive_import_test.go
+++ b/shortcuts/drive/drive_import_test.go
@@ -12,6 +12,9 @@ import (
"github.com/spf13/cobra"
+ "github.com/larksuite/cli/internal/cmdutil"
+ "github.com/larksuite/cli/internal/core"
+ "github.com/larksuite/cli/internal/httpmock"
_ "github.com/larksuite/cli/internal/vfs/localfileio"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -362,3 +365,206 @@ func TestDriveImportCreateTaskBodyKeepsEmptyMountKeyForRoot(t *testing.T) {
t.Fatalf("mount_key = %q, want %q", got, "fld_test")
}
}
+
+// driveImportMockEnv mounts the three stubs needed for a full +import run:
+// media upload_all -> import_tasks (create) -> import_tasks/ (poll).
+// Returns nothing; caller asserts on stdout via decodeDriveEnvelope.
+func driveImportMockEnv(t *testing.T, reg *httpmock.Registry, ticket string, pollData map[string]interface{}) {
+ t.Helper()
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/drive/v1/medias/upload_all",
+ Body: map[string]interface{}{
+ "code": 0, "msg": "ok",
+ "data": map[string]interface{}{"file_token": "file_import_media"},
+ },
+ })
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/drive/v1/import_tasks",
+ Body: map[string]interface{}{
+ "code": 0, "msg": "ok",
+ "data": map[string]interface{}{"ticket": ticket},
+ },
+ })
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "/open-apis/drive/v1/import_tasks/" + ticket,
+ Body: map[string]interface{}{
+ "code": 0, "msg": "ok",
+ "data": map[string]interface{}{"result": pollData},
+ },
+ })
+}
+
+// driveImportTestConfig builds a CliConfig for the import fallback tests.
+// The brand defaults to BrandFeishu when omitted; pass core.BrandLark to
+// exercise the larksuite.com branch of BuildResourceURL.
+func driveImportTestConfig(suffix string, brands ...core.LarkBrand) *core.CliConfig {
+ brand := core.BrandFeishu
+ if len(brands) > 0 {
+ brand = brands[0]
+ }
+ return &core.CliConfig{
+ AppID: "drive-import-fallback-" + suffix,
+ AppSecret: "test-secret",
+ Brand: brand,
+ }
+}
+
+func TestDriveImportFallbackURLWhenBackendOmitsIt(t *testing.T) {
+ f, stdout, _, reg := cmdutil.TestFactory(t, driveImportTestConfig("missing-url"))
+ driveImportMockEnv(t, reg, "ticket_fallback", map[string]interface{}{
+ "token": "doxcn_imported",
+ "type": "docx",
+ "job_status": float64(0),
+ // "url" deliberately omitted: import API frequently returns the doc
+ // without an absolute URL, leaving the CLI to backfill from token.
+ })
+
+ tmpDir := t.TempDir()
+ origDir, _ := os.Getwd()
+ if err := os.Chdir(tmpDir); err != nil {
+ t.Fatalf("Chdir: %v", err)
+ }
+ defer os.Chdir(origDir)
+ if err := os.WriteFile("notes.md", []byte("# Hi"), 0o644); err != nil {
+ t.Fatalf("WriteFile: %v", err)
+ }
+
+ if err := mountAndRunDrive(t, DriveImport, []string{
+ "+import", "--file", "notes.md", "--type", "docx", "--as", "user",
+ }, f, stdout); err != nil {
+ t.Fatalf("import should succeed, got: %v", err)
+ }
+
+ data := decodeDriveEnvelope(t, stdout)
+ if got, want := data["url"], "https://www.feishu.cn/docx/doxcn_imported"; got != want {
+ t.Fatalf("data.url = %#v, want %q (brand-standard fallback)", got, want)
+ }
+}
+
+func TestDriveImportPreservesBackendURL(t *testing.T) {
+ f, stdout, _, reg := cmdutil.TestFactory(t, driveImportTestConfig("preserve-url"))
+ driveImportMockEnv(t, reg, "ticket_preserve", map[string]interface{}{
+ "token": "doxcn_imported",
+ "type": "docx",
+ "job_status": float64(0),
+ "url": "https://tenant.larkoffice.com/docx/doxcn_imported",
+ })
+
+ tmpDir := t.TempDir()
+ origDir, _ := os.Getwd()
+ if err := os.Chdir(tmpDir); err != nil {
+ t.Fatalf("Chdir: %v", err)
+ }
+ defer os.Chdir(origDir)
+ if err := os.WriteFile("notes.md", []byte("# Hi"), 0o644); err != nil {
+ t.Fatalf("WriteFile: %v", err)
+ }
+
+ if err := mountAndRunDrive(t, DriveImport, []string{
+ "+import", "--file", "notes.md", "--type", "docx", "--as", "user",
+ }, f, stdout); err != nil {
+ t.Fatalf("import should succeed, got: %v", err)
+ }
+
+ data := decodeDriveEnvelope(t, stdout)
+ if got, want := data["url"], "https://tenant.larkoffice.com/docx/doxcn_imported"; got != want {
+ t.Fatalf("data.url = %#v, want backend tenant URL %q (fallback must not overwrite)", got, want)
+ }
+}
+
+func TestDriveImportFallbackURLWhenServerURLIsWhitespace(t *testing.T) {
+ f, stdout, _, reg := cmdutil.TestFactory(t, driveImportTestConfig("whitespace-url"))
+ driveImportMockEnv(t, reg, "ticket_whitespace", map[string]interface{}{
+ "token": "doxcn_imported",
+ "type": "docx",
+ "job_status": float64(0),
+ "url": " ", // whitespace-only must trigger fallback, not pass through.
+ })
+
+ tmpDir := t.TempDir()
+ origDir, _ := os.Getwd()
+ if err := os.Chdir(tmpDir); err != nil {
+ t.Fatalf("Chdir: %v", err)
+ }
+ defer os.Chdir(origDir)
+ if err := os.WriteFile("notes.md", []byte("# Hi"), 0o644); err != nil {
+ t.Fatalf("WriteFile: %v", err)
+ }
+
+ if err := mountAndRunDrive(t, DriveImport, []string{
+ "+import", "--file", "notes.md", "--type", "docx", "--as", "user",
+ }, f, stdout); err != nil {
+ t.Fatalf("import should succeed, got: %v", err)
+ }
+
+ data := decodeDriveEnvelope(t, stdout)
+ if got, want := data["url"], "https://www.feishu.cn/docx/doxcn_imported"; got != want {
+ t.Fatalf("data.url = %#v, want %q (whitespace-only backend URL must yield fallback)", got, want)
+ }
+}
+
+func TestDriveImportFallbackURLForLarkBrand(t *testing.T) {
+ f, stdout, _, reg := cmdutil.TestFactory(t, driveImportTestConfig("lark-brand", core.BrandLark))
+ driveImportMockEnv(t, reg, "ticket_lark", map[string]interface{}{
+ "token": "doxcn_imported",
+ "type": "docx",
+ "job_status": float64(0),
+ // "url" omitted to force the fallback through the lark host branch.
+ })
+
+ tmpDir := t.TempDir()
+ origDir, _ := os.Getwd()
+ if err := os.Chdir(tmpDir); err != nil {
+ t.Fatalf("Chdir: %v", err)
+ }
+ defer os.Chdir(origDir)
+ if err := os.WriteFile("notes.md", []byte("# Hi"), 0o644); err != nil {
+ t.Fatalf("WriteFile: %v", err)
+ }
+
+ if err := mountAndRunDrive(t, DriveImport, []string{
+ "+import", "--file", "notes.md", "--type", "docx", "--as", "user",
+ }, f, stdout); err != nil {
+ t.Fatalf("import should succeed, got: %v", err)
+ }
+
+ data := decodeDriveEnvelope(t, stdout)
+ if got, want := data["url"], "https://www.larksuite.com/docx/doxcn_imported"; got != want {
+ t.Fatalf("data.url = %#v, want %q (lark brand fallback)", got, want)
+ }
+}
+
+func TestDriveImportFallbackURLWhenServerTypeIsAlias(t *testing.T) {
+ f, stdout, _, reg := cmdutil.TestFactory(t, driveImportTestConfig("alias-type"))
+ driveImportMockEnv(t, reg, "ticket_alias", map[string]interface{}{
+ "token": "shtcn_imported",
+ "type": "sheets", // non-canonical alias the server may return
+ "job_status": float64(0),
+ })
+
+ tmpDir := t.TempDir()
+ origDir, _ := os.Getwd()
+ if err := os.Chdir(tmpDir); err != nil {
+ t.Fatalf("Chdir: %v", err)
+ }
+ defer os.Chdir(origDir)
+ if err := os.WriteFile("data.csv", []byte("a,b\n1,2\n"), 0o644); err != nil {
+ t.Fatalf("WriteFile: %v", err)
+ }
+
+ if err := mountAndRunDrive(t, DriveImport, []string{
+ "+import", "--file", "data.csv", "--type", "sheet", "--as", "user",
+ }, f, stdout); err != nil {
+ t.Fatalf("import should succeed, got: %v", err)
+ }
+
+ data := decodeDriveEnvelope(t, stdout)
+ // Server returned "sheets" (alias) — normalize falls back to the user
+ // --type "sheet", so BuildResourceURL picks the canonical /sheets/ path.
+ if got, want := data["url"], "https://www.feishu.cn/sheets/shtcn_imported"; got != want {
+ t.Fatalf("data.url = %#v, want %q (alias normalized via spec.DocType fallback)", got, want)
+ }
+}
diff --git a/shortcuts/drive/drive_io_test.go b/shortcuts/drive/drive_io_test.go
index 081679ac..eeee2576 100644
--- a/shortcuts/drive/drive_io_test.go
+++ b/shortcuts/drive/drive_io_test.go
@@ -317,6 +317,84 @@ func TestDriveUploadSmallFileToWiki(t *testing.T) {
}
}
+func TestDriveUploadFallbackURLForExplorerParent(t *testing.T) {
+ uploadTestConfig := &core.CliConfig{
+ AppID: "drive-upload-explorer-fallback-url", AppSecret: "test-secret", Brand: core.BrandFeishu,
+ }
+ f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
+
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/drive/v1/files/upload_all",
+ Body: map[string]interface{}{
+ "code": 0, "msg": "ok",
+ // upload_all only ever returns file_token; url is never present —
+ // this exercises the fallback path unconditionally for explorer
+ // parents.
+ "data": map[string]interface{}{"file_token": "file_explorer_small"},
+ },
+ })
+
+ tmpDir := t.TempDir()
+ origDir, _ := os.Getwd()
+ if err := os.Chdir(tmpDir); err != nil {
+ t.Fatalf("Chdir() error: %v", err)
+ }
+ defer os.Chdir(origDir)
+
+ if err := os.WriteFile("hello.bin", make([]byte, 64), 0644); err != nil {
+ t.Fatalf("WriteFile() error: %v", err)
+ }
+
+ err := mountAndRunDrive(t, DriveUpload, []string{
+ "+upload", "--file", "hello.bin", "--as", "user",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("upload should succeed, got: %v", err)
+ }
+
+ data := decodeDriveEnvelope(t, stdout)
+ if got, want := data["url"], "https://www.feishu.cn/file/file_explorer_small"; got != want {
+ t.Fatalf("data.url = %#v, want %q (brand-standard fallback)", got, want)
+ }
+}
+
+func TestDriveUploadOmitsURLForWikiParent(t *testing.T) {
+ uploadTestConfig := &core.CliConfig{
+ AppID: "drive-upload-wiki-no-url", AppSecret: "test-secret", Brand: core.BrandFeishu,
+ }
+ f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
+
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/drive/v1/files/upload_all",
+ Body: map[string]interface{}{
+ "code": 0, "msg": "ok",
+ "data": map[string]interface{}{"file_token": "file_wiki_small"},
+ },
+ })
+
+ tmpDir := t.TempDir()
+ withDriveWorkingDir(t, tmpDir)
+ if err := os.WriteFile("hello.bin", make([]byte, 64), 0644); err != nil {
+ t.Fatalf("WriteFile() error: %v", err)
+ }
+
+ err := mountAndRunDrive(t, DriveUpload, []string{
+ "+upload", "--file", "hello.bin",
+ "--wiki-token", "wikcn_parent",
+ "--as", "user",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("upload should succeed, got: %v", err)
+ }
+
+ data := decodeDriveEnvelope(t, stdout)
+ if _, ok := data["url"]; ok {
+ t.Fatalf("data.url should be omitted for wiki-hosted files (no standalone URL); got %#v", data["url"])
+ }
+}
+
func TestDriveUploadSmallFileAPIError(t *testing.T) {
uploadTestConfig := &core.CliConfig{
AppID: "drive-upload-small-err", AppSecret: "test-secret", Brand: core.BrandFeishu,
diff --git a/shortcuts/drive/drive_upload.go b/shortcuts/drive/drive_upload.go
index a938f3c6..43747ca2 100644
--- a/shortcuts/drive/drive_upload.go
+++ b/shortcuts/drive/drive_upload.go
@@ -146,6 +146,14 @@ var DriveUpload = common.Shortcut{
"file_name": fileName,
"size": fileSize,
}
+ // wiki-hosted files have no standalone /file/ URL — only the
+ // wiki node URL, which the upload response doesn't carry. Skip the
+ // fallback for parent_type=wiki rather than emit a link that 404s.
+ if target.ParentType == driveUploadParentTypeExplorer {
+ if u := common.BuildResourceURL(runtime.Config.Brand, "file", fileToken); u != "" {
+ out["url"] = u
+ }
+ }
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, fileToken, "file"); grant != nil {
out["permission_grant"] = grant
}
diff --git a/shortcuts/sheets/sheet_create.go b/shortcuts/sheets/sheet_create.go
index 69266a57..fba3534e 100644
--- a/shortcuts/sheets/sheet_create.go
+++ b/shortcuts/sheets/sheet_create.go
@@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"fmt"
+ "strings"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
@@ -108,7 +109,12 @@ var SheetCreate = common.Shortcut{
out := map[string]interface{}{
"spreadsheet_token": token,
"title": title,
- "url": spreadsheet["url"],
+ }
+ url, _ := spreadsheet["url"].(string)
+ if url = strings.TrimSpace(url); url != "" {
+ out["url"] = url
+ } else if u := common.BuildResourceURL(runtime.Config.Brand, "sheet", token); u != "" {
+ out["url"] = u
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, token, "sheet"); grant != nil {
out["permission_grant"] = grant
diff --git a/shortcuts/sheets/sheet_create_test.go b/shortcuts/sheets/sheet_create_test.go
index 11e43f37..67791be4 100644
--- a/shortcuts/sheets/sheet_create_test.go
+++ b/shortcuts/sheets/sheet_create_test.go
@@ -110,6 +110,142 @@ func TestSheetCreateUserSkipsPermissionGrantAugmentation(t *testing.T) {
}
}
+func TestSheetCreateFallbackURLWhenBackendOmitsIt(t *testing.T) {
+ t.Parallel()
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, ""))
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/sheets/v3/spreadsheets",
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "ok",
+ "data": map[string]interface{}{
+ "spreadsheet": map[string]interface{}{
+ "spreadsheet_token": "shtcn_new_sheet",
+ // "url" deliberately omitted to exercise the fallback.
+ },
+ },
+ },
+ })
+
+ err := runSheetCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--title", "项目排期",
+ "--as", "user",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ data := decodeSheetCreateEnvelope(t, stdout)
+ if got, want := data["url"], "https://www.feishu.cn/sheets/shtcn_new_sheet"; got != want {
+ t.Fatalf("url = %#v, want %q (brand-standard fallback)", got, want)
+ }
+}
+
+func TestSheetCreatePreservesBackendURL(t *testing.T) {
+ t.Parallel()
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, ""))
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/sheets/v3/spreadsheets",
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "ok",
+ "data": map[string]interface{}{
+ "spreadsheet": map[string]interface{}{
+ "spreadsheet_token": "shtcn_new_sheet",
+ "url": "https://tenant.larkoffice.com/sheets/shtcn_new_sheet",
+ },
+ },
+ },
+ })
+
+ err := runSheetCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--title", "项目排期",
+ "--as", "user",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ data := decodeSheetCreateEnvelope(t, stdout)
+ if got, want := data["url"], "https://tenant.larkoffice.com/sheets/shtcn_new_sheet"; got != want {
+ t.Fatalf("url = %#v, want backend tenant URL %q (fallback must not overwrite)", got, want)
+ }
+}
+
+func TestSheetCreateFallbackURLWhenBackendURLIsWhitespace(t *testing.T) {
+ t.Parallel()
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, ""))
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/sheets/v3/spreadsheets",
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "ok",
+ "data": map[string]interface{}{
+ "spreadsheet": map[string]interface{}{
+ "spreadsheet_token": "shtcn_new_sheet",
+ "url": " ", // whitespace-only must trigger fallback, not pass through.
+ },
+ },
+ },
+ })
+
+ err := runSheetCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--title", "项目排期",
+ "--as", "user",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ data := decodeSheetCreateEnvelope(t, stdout)
+ if got, want := data["url"], "https://www.feishu.cn/sheets/shtcn_new_sheet"; got != want {
+ t.Fatalf("url = %#v, want %q (whitespace-only backend URL must yield fallback)", got, want)
+ }
+}
+
+func TestSheetCreateTrimsPaddedBackendURL(t *testing.T) {
+ t.Parallel()
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, ""))
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/sheets/v3/spreadsheets",
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "ok",
+ "data": map[string]interface{}{
+ "spreadsheet": map[string]interface{}{
+ "spreadsheet_token": "shtcn_new_sheet",
+ "url": " https://tenant.larkoffice.com/sheets/shtcn_new_sheet ",
+ },
+ },
+ },
+ })
+
+ err := runSheetCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--title", "项目排期",
+ "--as", "user",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ data := decodeSheetCreateEnvelope(t, stdout)
+ if got, want := data["url"], "https://tenant.larkoffice.com/sheets/shtcn_new_sheet"; got != want {
+ t.Fatalf("url = %#v, want trimmed backend URL %q (whitespace must not leak into output)", got, want)
+ }
+}
+
func sheetCreateTestConfig(t *testing.T, userOpenID string) *core.CliConfig {
t.Helper()
diff --git a/shortcuts/wiki/wiki_node_create.go b/shortcuts/wiki/wiki_node_create.go
index 2c5c67ad..639fdddf 100644
--- a/shortcuts/wiki/wiki_node_create.go
+++ b/shortcuts/wiki/wiki_node_create.go
@@ -479,5 +479,8 @@ func augmentWikiNodeCreateOutput(runtime *common.RuntimeContext, execution *wiki
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, execution.Node.NodeToken, "wiki"); grant != nil {
out["permission_grant"] = grant
}
+ if u := common.BuildResourceURL(runtime.Config.Brand, "wiki", execution.Node.NodeToken); u != "" {
+ out["url"] = u
+ }
return out
}
diff --git a/shortcuts/wiki/wiki_node_create_test.go b/shortcuts/wiki/wiki_node_create_test.go
index e20f07f9..a057c25d 100644
--- a/shortcuts/wiki/wiki_node_create_test.go
+++ b/shortcuts/wiki/wiki_node_create_test.go
@@ -502,6 +502,9 @@ func TestWikiNodeCreateMountedExecuteWithExplicitSpaceID(t *testing.T) {
if envelope.Data["node_token"] != "wik_created" {
t.Fatalf("node_token = %#v, want %q", envelope.Data["node_token"], "wik_created")
}
+ if got, want := envelope.Data["url"], "https://www.feishu.cn/wiki/wik_created"; got != want {
+ t.Fatalf("url = %#v, want %q", got, want)
+ }
var captured map[string]interface{}
if err := json.Unmarshal(createStub.CapturedBody, &captured); err != nil {
diff --git a/span.log b/span.log
deleted file mode 100644
index e69de29b..00000000