diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index e68352f46..43b502eca 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -155,6 +155,19 @@ func (ctx *RuntimeContext) LarkSDK() *lark.Client { return ctx.larkSDK } +// EnsureScopes runs the same pre-flight scope check used by the framework +// before Validate, but on a caller-supplied set of scopes. Use it from a +// shortcut's Validate to enforce conditional scope requirements that depend +// on flag values (e.g. --delete-remote needing space:document:delete) so a +// destructive operation never starts on a token that can't finish it. +// +// Behavior matches checkShortcutScopes: when no token is available or the +// resolver doesn't expose scope metadata, this is a silent no-op — the +// downstream API call still surfaces missing_scope at runtime. +func (ctx *RuntimeContext) EnsureScopes(scopes []string) error { + return checkShortcutScopes(ctx.Factory, ctx.ctx, ctx.As(), ctx.Config, scopes) +} + // ── Flag accessors ── // Str returns a string flag value. diff --git a/shortcuts/drive/drive_push.go b/shortcuts/drive/drive_push.go new file mode 100644 index 000000000..304845626 --- /dev/null +++ b/shortcuts/drive/drive_push.go @@ -0,0 +1,794 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "path" + "path/filepath" + "sort" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + drivePushIfExistsOverwrite = "overwrite" + drivePushIfExistsSkip = "skip" + drivePushListPageSize = 200 + drivePushFileType = "file" + drivePushFolderType = "folder" +) + +type drivePushItem struct { + RelPath string `json:"rel_path"` + FileToken string `json:"file_token,omitempty"` + Action string `json:"action"` + Version string `json:"version,omitempty"` + SizeBytes int64 `json:"size_bytes,omitempty"` + Error string `json:"error,omitempty"` +} + +// remoteEntry captures everything we know about a single rel_path under the +// target Drive folder. type=file entries are upload candidates (we may +// overwrite them); other types (docx/sheet/folder/...) are recorded so +// --delete-remote can avoid touching them and so directory creation can +// short-circuit when a folder already exists. +type drivePushRemoteEntry struct { + FileToken string + Type string +} + +// DrivePush is a one-way, file-level mirror from a local directory onto a +// Drive folder: walks --local-dir, recursively lists --folder-token, and for +// each rel_path uploads (or overwrites) the corresponding Drive file. With +// --delete-remote --yes, any type=file entry on Drive that has no local +// counterpart is removed; online docs (docx/sheet/bitable/...), shortcuts +// and folders are never deleted, so this is "file-level" mirror — the +// command does not attempt to remove remote-only directories or close gaps +// in directory structure that exists on Drive but not locally. +// +// Only Drive entries with type=file participate in upload/overwrite/delete; +// online documents have no equivalent local binary. Sub-folders are created +// on Drive on demand via /open-apis/drive/v1/files/create_folder so the +// remote tree mirrors the local tree. +// +// The overwrite path passes the existing file_token as a form field on +// /open-apis/drive/v1/files/upload_all, mirroring the markdown +overwrite +// contract in shortcuts/markdown. The Drive backend exposing that field is +// being rolled out; until rollout completes, --if-exists defaults to "skip" +// so the safe path (do not touch existing remote files) is the default and +// callers must opt into "overwrite" explicitly. +var DrivePush = common.Shortcut{ + Service: "drive", + Command: "+push", + Description: "File-level mirror of a local directory onto a Drive folder (local → Drive; remote-only directories are not removed)", + Risk: "write", + // Narrowed scopes follow the precedent set by drive +status / +pull: + // drive:drive is policy-disabled in some tenants, so this shortcut sticks + // to the smallest set the *core* path needs. space:folder:create is + // always declared because mirroring a non-flat tree calls + // /open-apis/drive/v1/files/create_folder on demand and we want the + // framework's pre-flight scope check to catch missing grants before any + // upload — otherwise a partial push could land top-level files and then + // trip on a missing folder grant for a sub-tree, leaving a half-synced + // state. + // + // space:document:delete is intentionally NOT in the default set even + // though --delete-remote needs it. The framework pre-check (runner.go + // checkShortcutScopes) runs unconditionally before Validate / dry-run, + // so declaring it here would make every plain push (and every + // --dry-run) fail for callers that only granted upload scopes. + // + // Instead, Validate runs a *conditional* pre-flight via + // runtime.EnsureScopes when both --delete-remote and --yes are on, so + // the missing grant fails the run upfront — before any upload — + // rather than landing files first and tripping on missing_scope when + // the cleanup pass tries to delete. That avoids the half-synced state + // (files uploaded, orphans never cleaned up) that the unconditional + // pre-check would otherwise prevent only by also blocking plain + // pushes. + Scopes: []string{"drive:drive.metadata:readonly", "drive:file:upload", "space:folder:create"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true}, + {Name: "folder-token", Desc: "target Drive folder token", Required: true}, + {Name: "if-exists", Desc: "policy when a Drive file already exists at the same rel_path (default: skip — safe; opt into overwrite explicitly while the backend version field is rolling out)", Default: drivePushIfExistsSkip, Enum: []string{drivePushIfExistsOverwrite, drivePushIfExistsSkip}}, + {Name: "delete-remote", Type: "bool", Desc: "delete Drive files absent locally (file-level mirror; remote-only directories are not removed); requires --yes"}, + {Name: "yes", Type: "bool", Desc: "confirm --delete-remote before deleting Drive files"}, + }, + Tips: []string{ + "This is a file-level mirror: only type=file entries are uploaded, overwritten or deleted. Online docs (docx, sheet, bitable, mindnote, slides), shortcuts, and remote-only directories are never touched.", + "Local directory structure (including empty directories) is mirrored to Drive via create_folder; existing remote folders are reused.", + "Default --if-exists=skip is the safe choice while the upload_all overwrite-version field is rolling out. Pass --if-exists=overwrite to replace remote bytes; on tenants without the field it surfaces a structured api_error and the run exits non-zero.", + "--delete-remote requires --yes; without --yes the command is rejected upfront so a stray flag never deletes anything.", + "--delete-remote --yes also requires the space:document:delete scope. Validate runs a dynamic pre-flight check when the flag is on, so a missing grant fails the run before any upload — preventing a half-synced state where files were uploaded but the cleanup pass cannot delete.", + "Item-level failures (upload, overwrite, folder, delete) bump summary.failed and the run exits non-zero. If any upload or folder step fails, the --delete-remote phase is skipped entirely so a partial upload never triggers remote deletion.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + localDir := strings.TrimSpace(runtime.Str("local-dir")) + folderToken := strings.TrimSpace(runtime.Str("folder-token")) + if localDir == "" { + return common.FlagErrorf("--local-dir is required") + } + if folderToken == "" { + return common.FlagErrorf("--folder-token is required") + } + if err := validate.ResourceName(folderToken, "--folder-token"); err != nil { + return output.ErrValidation("%s", err) + } + if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil { + return output.ErrValidation("%s", err) + } + info, err := runtime.FileIO().Stat(localDir) + if err != nil { + return common.WrapInputStatError(err) + } + if !info.IsDir() { + return output.ErrValidation("--local-dir is not a directory: %s", localDir) + } + if runtime.Bool("delete-remote") && !runtime.Bool("yes") { + return output.ErrValidation("--delete-remote requires --yes (high-risk: deletes Drive files absent locally)") + } + // Conditional scope pre-check: when --delete-remote --yes is set, the + // run will issue DELETE /open-apis/drive/v1/files/ after the + // upload phase. The default Scopes list intentionally omits + // space:document:delete so plain pushes don't get blocked on a grant + // they don't need (see the Scopes block above), but at this point we + // know the run will need it — pre-flight here so a missing grant + // fails before any upload, instead of after, which would otherwise + // leave the tenant in a half-synced state (files uploaded, remote + // orphans never cleaned up). EnsureScopes is a silent no-op when no + // token / scope metadata is available, so test envs and tenants + // where the resolver doesn't expose scopes still proceed and rely on + // the API-level missing_scope error. + if runtime.Bool("delete-remote") && runtime.Bool("yes") { + if err := runtime.EnsureScopes([]string{"space:document:delete"}); err != nil { + return err + } + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + Desc("Walk --local-dir, recursively list --folder-token, then upload new files, overwrite (when --if-exists=overwrite) or skip existing, and (when --delete-remote --yes is set) delete Drive files absent locally."). + GET("/open-apis/drive/v1/files"). + Set("folder_token", runtime.Str("folder-token")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + localDir := strings.TrimSpace(runtime.Str("local-dir")) + folderToken := strings.TrimSpace(runtime.Str("folder-token")) + ifExists := strings.TrimSpace(runtime.Str("if-exists")) + if ifExists == "" { + // Default to the safe "skip" policy: do not touch already-present + // remote files. Callers must pass --if-exists=overwrite to opt + // into the overwrite-with-version path that depends on the + // rolling-out upload_all `file_token`/`version` protocol field. + ifExists = drivePushIfExistsSkip + } + deleteRemote := runtime.Bool("delete-remote") + + // Resolve --local-dir to its canonical absolute path before walking. + // SafeInputPath fully evaluates symlinks across the entire path, + // which closes the kernel-level escape route that filepath.Clean + // alone misses (e.g. "link/.." string-cleans to "." but the kernel + // resolves through link's target's parent). Walking the canonical + // root sidesteps that, and the matching cwd canonical lets each + // absolute walk hit be converted to a cwd-relative path that + // FileIO.Open's SafeInputPath check still accepts. + safeRoot, err := validate.SafeInputPath(localDir) + if err != nil { + return output.ErrValidation("--local-dir: %s", err) + } + cwdCanonical, err := validate.SafeInputPath(".") + if err != nil { + return output.ErrValidation("could not resolve cwd: %s", err) + } + + fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir) + localFiles, localDirs, err := drivePushWalkLocal(safeRoot, cwdCanonical) + if err != nil { + return err + } + + fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken)) + remoteFiles, remoteFolders, err := drivePushListRemote(ctx, runtime, folderToken, "") + if err != nil { + return err + } + + var uploaded, skipped, failed, deletedRemote int + items := make([]drivePushItem, 0) + // uploadFailed tracks whether any folder-creation, upload or + // overwrite step failed. The --delete-remote phase only runs when + // this stays false: a partial upload that then proceeds to delete + // remote orphans would leave the tenant half-synced (files missing + // locally and now on Drive too), which is the worst-of-both-worlds + // outcome the review flagged. + uploadFailed := false + + // folderCache holds rel_path → folder_token. Seeded from the remote + // listing (so we don't recreate folders that already exist) and + // extended in-place as drivePushEnsureFolder mints new ones. + folderCache := map[string]string{"": folderToken} + for relDir, entry := range remoteFolders { + folderCache[relDir] = entry.FileToken + } + + // Mirror local directory structure first, so empty directories + // are not silently dropped. Pre-creating also frees the upload + // loop from doing on-demand mkdir for every file's parent chain + // (the cache makes both paths idempotent, but pre-creation keeps + // items[] in a tidy "folders, then files" shape). + for _, relDir := range localDirs { + if _, alreadyRemote := folderCache[relDir]; alreadyRemote { + // Folder already exists on Drive — nothing to do; staying + // silent (no items[] entry) avoids noise on reruns. + continue + } + if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil { + items = append(items, drivePushItem{RelPath: relDir, Action: "failed", Error: ensureErr.Error()}) + failed++ + uploadFailed = true + continue + } + items = append(items, drivePushItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created"}) + } + + // Upload local-only and overwrite/skip already-present files in a + // stable order so output is reproducible. + localPaths := make([]string, 0, len(localFiles)) + for p := range localFiles { + localPaths = append(localPaths, p) + } + sort.Strings(localPaths) + + for _, rel := range localPaths { + localFile := localFiles[rel] + + if entry, ok := remoteFiles[rel]; ok { + if ifExists == drivePushIfExistsSkip { + items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "skipped", SizeBytes: localFile.Size}) + skipped++ + continue + } + token, version, upErr := drivePushUploadFile(ctx, runtime, localFile, entry.FileToken, folderToken) + if upErr != nil { + // Token contract on overwrite failure: an in-place + // overwrite preserves the file's token, so the + // existing entry.FileToken is normally still the + // authoritative pointer to the (possibly already + // rewritten) Drive file. But the protocol does not + // strictly forbid the backend from minting a new + // token, and a partial-success response can return a + // non-empty file_token alongside an error (the + // missing-version case below is the immediate + // concern: bytes hit the disk, version field + // missing, so we surface a structured error). Prefer + // the freshly returned token when one was produced, + // fall back to entry.FileToken otherwise — that way + // callers still have a usable handle to whatever + // state Drive ended up in. + failedToken := token + if failedToken == "" { + failedToken = entry.FileToken + } + items = append(items, drivePushItem{RelPath: rel, FileToken: failedToken, Action: "failed", SizeBytes: localFile.Size, Error: upErr.Error()}) + failed++ + uploadFailed = true + continue + } + items = append(items, drivePushItem{RelPath: rel, FileToken: token, Action: "overwritten", Version: version, SizeBytes: localFile.Size}) + uploaded++ + continue + } + + parentRel := drivePushParentRel(rel) + parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache) + if ensureErr != nil { + items = append(items, drivePushItem{RelPath: rel, Action: "failed", SizeBytes: localFile.Size, Error: ensureErr.Error()}) + failed++ + uploadFailed = true + continue + } + token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken) + if upErr != nil { + items = append(items, drivePushItem{RelPath: rel, Action: "failed", SizeBytes: localFile.Size, Error: upErr.Error()}) + failed++ + uploadFailed = true + continue + } + items = append(items, drivePushItem{RelPath: rel, FileToken: token, Action: "uploaded", SizeBytes: localFile.Size}) + uploaded++ + } + + // Skip the delete phase entirely on any upstream failure. The orphan + // loop deletes by remote token and is unrecoverable; running it + // after a failed upload risks deleting a file the partial upload + // would have replaced on a successful re-run, leaving the tenant + // in a worse state than where we started. Surface the skipped + // delete as a hint in stderr so operators know the cleanup pass + // is pending and can re-run after fixing the upload. + if deleteRemote && uploadFailed { + fmt.Fprintf(runtime.IO().ErrOut, + "Skipping --delete-remote: %d earlier failure(s) — re-run after resolving them.\n", + failed) + } + if deleteRemote && !uploadFailed { + // Stable iteration order so failures (and tests) are deterministic. + remoteRelPaths := make([]string, 0, len(remoteFiles)) + for p := range remoteFiles { + remoteRelPaths = append(remoteRelPaths, p) + } + sort.Strings(remoteRelPaths) + + for _, rel := range remoteRelPaths { + if _, ok := localFiles[rel]; ok { + continue + } + entry := remoteFiles[rel] + if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil { + items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "delete_failed", Error: err.Error()}) + failed++ + continue + } + items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "deleted_remote"}) + deletedRemote++ + } + } + + runtime.Out(map[string]interface{}{ + "summary": map[string]interface{}{ + "uploaded": uploaded, + "skipped": skipped, + "failed": failed, + "deleted_remote": deletedRemote, + }, + "items": items, + }, nil) + // Bump the exit code on any item-level failure (upload, overwrite, + // folder, or delete) so callers / scripts / agents can react. The + // summary + items[] envelope was just written to stdout via Out(), + // so ErrBare here only affects the exit code — the structured + // per-item context is still in the stdout JSON. + if failed > 0 { + return output.ErrBare(output.ExitAPI) + } + return nil + }, +} + +// drivePushLocalFile records what we need to upload a local regular file: +// a rel_path used for output and Drive layout, the cwd-relative path that +// FileIO.Open accepts, the file size (drives single/multipart selection), +// and the basename used as Drive's file_name. +type drivePushLocalFile struct { + RelPath string + OpenPath string + FileName string + Size int64 +} + +// drivePushWalkLocal walks the canonical absolute root produced by +// SafeInputPath. Same threat model as +pull/+status: the validated root +// is not a symlink itself, and WalkDir's default policy (do not follow +// child symlinks) keeps the traversal inside that canonical subtree, so +// the OpenPath we hand to FileIO.Open stays inside cwd. +// +// Returns two views: +// - files: rel_path → file metadata; drives the upload/skip/overwrite loop. +// - dirs: every non-root directory rel_path encountered. Used to mirror +// empty directories (which would otherwise be silently dropped because +// the upload loop only iterates files); non-empty directories appear +// here too but are harmless because drivePushEnsureFolder is cached. +func drivePushWalkLocal(root, cwdCanonical string) (map[string]drivePushLocalFile, []string, error) { + files := make(map[string]drivePushLocalFile) + dirsSet := make(map[string]struct{}) + // FileIO has no walker today and shortcuts can't import internal/vfs + // (depguard rule shortcuts-no-vfs). The walk root is the canonical + // absolute path returned by validate.SafeInputPath, so it is no + // longer a symlink itself, and WalkDir's default child-symlink + // policy keeps the traversal inside the validated subtree. + err := filepath.WalkDir(root, func(absPath string, d fs.DirEntry, walkErr error) error { //nolint:forbidigo // see comment above + if walkErr != nil { + return walkErr + } + rel, err := filepath.Rel(root, absPath) + if err != nil { + return err + } + relSlash := filepath.ToSlash(rel) + if d.IsDir() { + // Skip the root itself ("."): that is --folder-token, already + // the parent we mirror into, not a sub-folder we need to + // create. + if relSlash != "." { + dirsSet[relSlash] = struct{}{} + } + return nil + } + if !d.Type().IsRegular() { + return nil + } + relToCwd, err := filepath.Rel(cwdCanonical, absPath) + if err != nil { + return err + } + info, err := d.Info() + if err != nil { + return err + } + files[relSlash] = drivePushLocalFile{ + RelPath: relSlash, + OpenPath: relToCwd, + FileName: filepath.Base(rel), + Size: info.Size(), + } + return nil + }) + if err != nil { + return nil, nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err) + } + dirs := make([]string, 0, len(dirsSet)) + for d := range dirsSet { + dirs = append(dirs, d) + } + // Shallow-first ordering ensures parents are created before children; + // drivePushEnsureFolder also handles parent recursion on its own, but + // emitting items[] in shallow-first order matches what users expect. + sort.Slice(dirs, func(i, j int) bool { + di, dj := strings.Count(dirs[i], "/"), strings.Count(dirs[j], "/") + if di != dj { + return di < dj + } + return dirs[i] < dirs[j] + }) + return files, dirs, nil +} + +// drivePushListRemote recursively lists a Drive folder. +// +// Returns two views: +// - files: rel_path → entry, for type=file. Drives upload/overwrite +// decisions and (with --delete-remote) the deletion loop. +// - folders: rel_path → entry, for every type=folder hit. Lets the upload +// path skip create_folder when an intermediate folder already exists, +// and keeps directory recreation idempotent across reruns. +// +// Online docs (docx/sheet/bitable/...) and shortcuts are intentionally NOT +// returned. They are never deleted by --delete-remote (the contract says +// type=file only) and the upload path doesn't need them either: if an +// online doc happens to share a rel_path with a local file, the upload +// will create a sibling type=file under the same parent, which is the +// same behavior `lark-cli drive +upload` already has. +// +// TODO(post-#692/#696): when drive +status / +pull merge, lift this and the +// matching helpers in drive_status.go / drive_pull.go into a shared +// listRemoteFolderFiles in the drive package. +func drivePushListRemote(ctx context.Context, runtime *common.RuntimeContext, folderToken, relBase string) (map[string]drivePushRemoteEntry, map[string]drivePushRemoteEntry, error) { + files := make(map[string]drivePushRemoteEntry) + folders := make(map[string]drivePushRemoteEntry) + pageToken := "" + for { + params := map[string]interface{}{ + "folder_token": folderToken, + "page_size": fmt.Sprint(drivePushListPageSize), + } + if pageToken != "" { + params["page_token"] = pageToken + } + result, err := runtime.CallAPI("GET", "/open-apis/drive/v1/files", params, nil) + if err != nil { + return nil, nil, err + } + rawFiles, _ := result["files"].([]interface{}) + for _, item := range rawFiles { + f, ok := item.(map[string]interface{}) + if !ok { + continue + } + fType := common.GetString(f, "type") + fName := common.GetString(f, "name") + fToken := common.GetString(f, "token") + if fName == "" || fToken == "" { + continue + } + rel := drivePushJoinRel(relBase, fName) + switch fType { + case drivePushFileType: + files[rel] = drivePushRemoteEntry{FileToken: fToken, Type: fType} + case drivePushFolderType: + folders[rel] = drivePushRemoteEntry{FileToken: fToken, Type: fType} + subFiles, subFolders, err := drivePushListRemote(ctx, runtime, fToken, rel) + if err != nil { + return nil, nil, err + } + for k, v := range subFiles { + files[k] = v + } + for k, v := range subFolders { + folders[k] = v + } + default: + // online doc / shortcut — intentionally ignored. + } + } + hasMore, nextToken := common.PaginationMeta(result) + if !hasMore || nextToken == "" { + break + } + pageToken = nextToken + } + return files, folders, nil +} + +// drivePushEnsureFolder ensures a folder chain (rel_dir relative to the root +// folder identified by rootFolderToken) exists on Drive, creating any +// missing segments via /open-apis/drive/v1/files/create_folder. Returns the +// token of the deepest folder, suitable as parent_node for the upload. +// +// folderCache is shared with the caller so each segment is only created +// once per push, and so subsequent uploads under the same sub-tree reuse +// the freshly minted folder token without an extra round trip. +func drivePushEnsureFolder(ctx context.Context, runtime *common.RuntimeContext, rootFolderToken, relDir string, folderCache map[string]string) (string, error) { + if token, ok := folderCache[relDir]; ok { + return token, nil + } + parentRel, name := drivePushSplitRel(relDir) + parentToken, err := drivePushEnsureFolder(ctx, runtime, rootFolderToken, parentRel, folderCache) + if err != nil { + return "", err + } + + data, err := runtime.CallAPI( + "POST", + "/open-apis/drive/v1/files/create_folder", + nil, + map[string]interface{}{ + "name": name, + "folder_token": parentToken, + }, + ) + if err != nil { + return "", err + } + token := common.GetString(data, "token") + if token == "" { + return "", output.Errorf(output.ExitAPI, "api_error", "create_folder for %q returned no folder token", relDir) + } + folderCache[relDir] = token + return token, nil +} + +// drivePushUploadFile uploads (or overwrites) a single local file. When +// existingToken is non-empty, the request adds the file_token form field to +// trigger overwrite-with-version semantics on the backend; the response is +// expected to carry a non-empty `version`, which is propagated to the +// caller for the items[].version field. When existingToken is empty, this +// is a fresh upload under parentToken. +// +// Files larger than common.MaxDriveMediaUploadSinglePartSize fall back to +// the three-step prepare/part/finish flow, which mirrors drive +upload's +// existing multipart logic. +func drivePushUploadFile(ctx context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, string, error) { + if file.Size > common.MaxDriveMediaUploadSinglePartSize { + token, err := drivePushUploadMultipart(ctx, runtime, file, existingToken, parentToken) + // Multipart finish does not return version on the existing + // /open-apis/drive/v1/files/upload_finish contract; surface an + // empty version in that case rather than fabricating one. The + // markdown +overwrite path has the same gap and is tracked for a + // follow-up once the multipart endpoint exposes the field. + return token, "", err + } + return drivePushUploadAll(ctx, runtime, file, existingToken, parentToken) +} + +func drivePushUploadAll(_ context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, string, error) { + f, err := runtime.FileIO().Open(file.OpenPath) + if err != nil { + return "", "", common.WrapInputStatError(err) + } + defer f.Close() + + fd := larkcore.NewFormdata() + fd.AddField("file_name", file.FileName) + fd.AddField("parent_type", driveUploadParentTypeExplorer) + fd.AddField("parent_node", parentToken) + fd.AddField("size", fmt.Sprintf("%d", file.Size)) + if existingToken != "" { + // Overwrite mode: the backend interprets a non-empty file_token on + // upload_all as "replace this file's content and bump its version", + // matching the markdown +overwrite contract. + fd.AddField("file_token", existingToken) + } + fd.AddFile("file", f) + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: "/open-apis/drive/v1/files/upload_all", + Body: fd, + }, larkcore.WithFileUpload()) + if err != nil { + var exitErr *output.ExitError + if errors.As(err, &exitErr) { + return "", "", err + } + return "", "", output.ErrNetwork("upload failed: %v", err) + } + + var result map[string]interface{} + if err := json.Unmarshal(apiResp.RawBody, &result); err != nil { + return "", "", output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err) + } + // Extract the token before the larkCode check: the backend can produce + // a partial-success response (code != 0 alongside a non-empty + // data.file_token) where bytes have already landed under that token. + // Returning "" here would force the caller to fall back to + // entry.FileToken and silently lose the token Drive actually used, + // defeating the overwrite-error token-stability handling in Execute. + data, _ := result["data"].(map[string]interface{}) + token := common.GetString(data, "file_token") + if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 { + msg, _ := result["msg"].(string) + return token, "", output.ErrAPI(larkCode, fmt.Sprintf("upload failed: [%d] %s", larkCode, msg), result["error"]) + } + if token == "" { + return "", "", output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned") + } + version := common.GetString(data, "version") + if version == "" { + // Some backends return the version under data_version; accept either + // per the markdown +overwrite contract. + version = common.GetString(data, "data_version") + } + if existingToken != "" && version == "" { + // The protocol guarantees a non-empty version on overwrite. If the + // deployed backend hasn't shipped the field yet we surface the gap + // rather than report a phantom success — callers can downgrade to + // --if-exists=skip in the meantime. + return token, "", output.Errorf(output.ExitAPI, "api_error", "overwrite for %q succeeded but no version was returned by upload_all", file.RelPath) + } + return token, version, nil +} + +func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, error) { + prepareBody := map[string]interface{}{ + "file_name": file.FileName, + "parent_type": driveUploadParentTypeExplorer, + "parent_node": parentToken, + "size": file.Size, + } + if existingToken != "" { + prepareBody["file_token"] = existingToken + } + prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody) + if err != nil { + return "", err + } + + uploadID := common.GetString(prepareResult, "upload_id") + blockSize := int64(common.GetFloat(prepareResult, "block_size")) + blockNum := int(common.GetFloat(prepareResult, "block_num")) + if uploadID == "" || blockSize <= 0 || blockNum <= 0 { + return "", output.Errorf(output.ExitAPI, "api_error", + "upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d", + uploadID, blockSize, blockNum) + } + + fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload: %s, block size %s, %d block(s)\n", + common.FormatSize(file.Size), common.FormatSize(blockSize), blockNum) + + // Open the local file ONCE for the whole multipart loop. fileio.File + // implements io.ReaderAt, so each block is a fresh + // io.NewSectionReader over a shared fd — no need to reopen N times + // (which is what drive +upload's existing multipart helper does and + // what the original drive_push copy inherited; that pattern wastes + // one Open + Close + path-validation per block). + partFile, err := runtime.FileIO().Open(file.OpenPath) + if err != nil { + return "", common.WrapInputStatError(err) + } + defer partFile.Close() + + for seq := 0; seq < blockNum; seq++ { + offset := int64(seq) * blockSize + partSize := blockSize + if remaining := file.Size - offset; partSize > remaining { + partSize = remaining + } + + fd := larkcore.NewFormdata() + fd.AddField("upload_id", uploadID) + fd.AddField("seq", fmt.Sprintf("%d", seq)) + fd.AddField("size", fmt.Sprintf("%d", partSize)) + fd.AddFile("file", io.NewSectionReader(partFile, offset, partSize)) + + apiResp, doErr := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: "/open-apis/drive/v1/files/upload_part", + Body: fd, + }, larkcore.WithFileUpload()) + if doErr != nil { + var exitErr *output.ExitError + if errors.As(doErr, &exitErr) { + return "", doErr + } + return "", output.ErrNetwork("upload part %d/%d failed: %v", seq+1, blockNum, doErr) + } + + var partResult map[string]interface{} + if err := json.Unmarshal(apiResp.RawBody, &partResult); err != nil { + return "", output.Errorf(output.ExitAPI, "api_error", "upload part %d/%d: invalid response JSON: %v", seq+1, blockNum, err) + } + if larkCode := int(common.GetFloat(partResult, "code")); larkCode != 0 { + msg, _ := partResult["msg"].(string) + return "", output.ErrAPI(larkCode, fmt.Sprintf("upload part %d/%d failed: [%d] %s", seq+1, blockNum, larkCode, msg), partResult["error"]) + } + fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, blockNum, common.FormatSize(partSize)) + } + + finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{ + "upload_id": uploadID, + "block_num": blockNum, + }) + if err != nil { + return "", err + } + token := common.GetString(finishResult, "file_token") + if token == "" { + return "", output.Errorf(output.ExitAPI, "api_error", "upload_finish succeeded but no file_token returned") + } + return token, nil +} + +// drivePushDeleteFile deletes a single Drive file (type=file). Folders are +// never reached here because --delete-remote only iterates the type=file +// subset of the remote listing. +func drivePushDeleteFile(_ context.Context, runtime *common.RuntimeContext, fileToken string) error { + _, err := runtime.CallAPI( + "DELETE", + fmt.Sprintf("/open-apis/drive/v1/files/%s", validate.EncodePathSegment(fileToken)), + map[string]interface{}{"type": drivePushFileType}, + nil, + ) + return err +} + +func drivePushJoinRel(base, name string) string { + if base == "" { + return name + } + return base + "/" + name +} + +// drivePushParentRel returns the parent rel_path of rel ("" when the file +// lives at the root). The local walker emits forward-slash rel_paths so +// path.Dir is the right primitive here, not filepath.Dir. +func drivePushParentRel(rel string) string { + dir := path.Dir(rel) + if dir == "." || dir == "/" { + return "" + } + return dir +} + +// drivePushSplitRel splits a non-empty rel into (parent, basename), both +// using forward slashes. +func drivePushSplitRel(rel string) (string, string) { + idx := strings.LastIndex(rel, "/") + if idx < 0 { + return "", rel + } + return rel[:idx], rel[idx+1:] +} diff --git a/shortcuts/drive/drive_push_test.go b/shortcuts/drive/drive_push_test.go new file mode 100644 index 000000000..f58f0fae5 --- /dev/null +++ b/shortcuts/drive/drive_push_test.go @@ -0,0 +1,1194 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// countingOpenProvider wraps a fileio.Provider and counts FileIO.Open +// calls. Used by the multipart test to pin the single-shared-fd +// optimization: a regression to reopen-per-block would push the +// counter above 1. +type countingOpenProvider struct { + inner fileio.Provider + opens *atomic.Int32 +} + +func (p *countingOpenProvider) Name() string { return "counting-open" } + +func (p *countingOpenProvider) ResolveFileIO(ctx context.Context) fileio.FileIO { + return &countingOpenFileIO{inner: p.inner.ResolveFileIO(ctx), opens: p.opens} +} + +type countingOpenFileIO struct { + inner fileio.FileIO + opens *atomic.Int32 +} + +func (c *countingOpenFileIO) Open(name string) (fileio.File, error) { + c.opens.Add(1) + return c.inner.Open(name) +} +func (c *countingOpenFileIO) Stat(name string) (fileio.FileInfo, error) { + return c.inner.Stat(name) +} +func (c *countingOpenFileIO) ResolvePath(p string) (string, error) { return c.inner.ResolvePath(p) } +func (c *countingOpenFileIO) Save(p string, opts fileio.SaveOptions, body io.Reader) (fileio.SaveResult, error) { + return c.inner.Save(p, opts, body) +} + +// TestDrivePushUploadsAndCreatesParents verifies the happy path: a local +// tree with a top-level file and a subdirectory is mirrored onto a Drive +// folder, with create_folder called once for the missing sub-folder. +func TestDrivePushUploadsAndCreatesParents(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join("local", "a.txt"), []byte("AAA"), 0o644); err != nil { + t.Fatalf("WriteFile a: %v", err) + } + if err := os.WriteFile(filepath.Join("local", "sub", "b.txt"), []byte("BBB"), 0o644); err != nil { + t.Fatalf("WriteFile b: %v", err) + } + + // Empty remote: no files, no folders. + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{"files": []interface{}{}, "has_more": false}, + }, + }) + + // Upload a.txt into the root folder. + 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": "tok_a"}, + }, + }) + + // create_folder for "sub", returns a fresh token. + 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_sub"}, + }, + }) + + // Upload b.txt into the freshly created sub-folder. + 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": "tok_b"}, + }, + }) + + err := mountAndRunDrive(t, DrivePush, []string{ + "+push", + "--local-dir", "local", + "--folder-token", "folder_root", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + out := stdout.String() + if !strings.Contains(out, `"uploaded": 2`) { + t.Errorf("expected uploaded=2, got: %s", out) + } + if !strings.Contains(out, `"failed": 0`) { + t.Errorf("expected failed=0, got: %s", out) + } + if !strings.Contains(out, `"deleted_remote": 0`) { + t.Errorf("expected deleted_remote=0 (flag not set), got: %s", out) + } +} + +// TestDrivePushOverwritesWhenIfExistsOverwrite verifies that a local file +// whose rel_path already maps to a type=file on Drive is sent through +// upload_all with the existing file_token in the form body, and that the +// returned version is propagated to items[].version. +func TestDrivePushOverwritesWhenIfExistsOverwrite(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join("local", "keep.txt"), []byte("local-new"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_keep_old", "name": "keep.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + + uploadStub := &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": "tok_keep_new", + "version": "v42", + }, + }, + } + reg.Register(uploadStub) + + err := mountAndRunDrive(t, DrivePush, []string{ + "+push", + "--local-dir", "local", + "--folder-token", "folder_root", + // Default --if-exists is now "skip"; opt into overwrite explicitly + // to exercise the file_token-on-upload_all path this test pins. + "--if-exists", "overwrite", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + out := stdout.String() + if !strings.Contains(out, `"uploaded": 1`) { + t.Errorf("expected uploaded=1, got: %s", out) + } + if !strings.Contains(out, `"action": "overwritten"`) { + t.Errorf("expected action=overwritten, got: %s", out) + } + if !strings.Contains(out, `"version": "v42"`) { + t.Errorf("expected version propagated, got: %s", out) + } + + // The overwrite request must carry the existing file_token in the + // form body so the backend knows to bump the existing file's version + // rather than create a sibling. Decode the captured multipart body + // using the existing decoder helper. + body := decodeDriveMultipartBody(t, uploadStub) + if got := body.Fields["file_token"]; got != "tok_keep_old" { + t.Fatalf("upload_all form file_token = %q, want tok_keep_old", got) + } + if got := body.Fields["file_name"]; got != "keep.txt" { + t.Fatalf("upload_all form file_name = %q, want keep.txt", got) + } +} + +// TestDrivePushDefaultsToSkipForExistingRemote pins the new default. Until +// the upload_all overwrite/version protocol field is fully rolled out, the +// default --if-exists is "skip" so a first-time push against a non-empty +// folder never trips on the missing-version branch by surprise. A test +// that registers no upload_all stub would fail with "unmatched stub" if +// the default were still "overwrite". +func TestDrivePushDefaultsToSkipForExistingRemote(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join("local", "keep.txt"), []byte("local"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_keep", "name": "keep.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + + // Intentionally NO upload_all stub: with the new default, the file + // is silently skipped — any upload_all call would trip the registry's + // strict no-stub-or-die contract. + + err := mountAndRunDrive(t, DrivePush, []string{ + "+push", + "--local-dir", "local", + "--folder-token", "folder_root", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + out := stdout.String() + if !strings.Contains(out, `"skipped": 1`) { + t.Errorf("expected default behavior to skip existing remote files, got: %s", out) + } + if !strings.Contains(out, `"uploaded": 0`) { + t.Errorf("expected uploaded=0 under default --if-exists=skip, got: %s", out) + } + if !strings.Contains(out, `"failed": 0`) { + t.Errorf("expected failed=0, got: %s", out) + } +} + +// TestDrivePushSkipsWhenIfExistsSkip verifies --if-exists=skip leaves Drive +// content untouched and counts the file under summary.skipped without +// hitting upload_all. +func TestDrivePushSkipsWhenIfExistsSkip(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join("local", "keep.txt"), []byte("local"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_keep", "name": "keep.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + + err := mountAndRunDrive(t, DrivePush, []string{ + "+push", + "--local-dir", "local", + "--folder-token", "folder_root", + "--if-exists", "skip", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + out := stdout.String() + if !strings.Contains(out, `"skipped": 1`) { + t.Errorf("expected skipped=1, got: %s", out) + } + if !strings.Contains(out, `"uploaded": 0`) { + t.Errorf("expected uploaded=0 with --if-exists=skip, got: %s", out) + } + + // reg.Verify is implicit via the registry's no-stub-or-die contract: + // if --if-exists=skip had triggered an upload_all anyway, the request + // would 404 against the registry and the run would have errored above. +} + +// TestDrivePushDeleteRemoteRequiresYes locks in the upfront safety guard: +// --delete-remote without --yes must be refused before any list / upload +// happens, so a stray flag never silently deletes anything. +func TestDrivePushDeleteRemoteRequiresYes(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + err := mountAndRunDrive(t, DrivePush, []string{ + "+push", + "--local-dir", "local", + "--folder-token", "folder_root", + "--delete-remote", + "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected validation error for --delete-remote without --yes, got nil") + } + if !strings.Contains(err.Error(), "--delete-remote") || !strings.Contains(err.Error(), "--yes") { + t.Fatalf("error must mention --delete-remote and --yes, got: %v", err) + } +} + +// TestDrivePushDeleteRemoteSkipsOnlineDocs is the load-bearing safety +// regression: with --delete-remote --yes, the command must only DELETE +// type=file Drive entries that have no local counterpart. Online docs +// (docx/sheet/bitable/...), shortcuts and folders that share a rel_path +// with a missing local file must be left alone. +func TestDrivePushDeleteRemoteSkipsOnlineDocs(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + // One local file that already exists remotely and matches by rel_path. + if err := os.WriteFile(filepath.Join("local", "kept.txt"), []byte("kept"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + // Remote contains: + // - kept.txt: a type=file with a local counterpart -> overwritten + // - orphan.txt: a type=file without local counterpart -> deleted_remote + // - notes.docx: an online doc -> must be left alone (not deleted) + // - link: a shortcut -> must be left alone + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_kept", "name": "kept.txt", "type": "file"}, + map[string]interface{}{"token": "tok_orphan", "name": "orphan.txt", "type": "file"}, + map[string]interface{}{"token": "tok_doc", "name": "notes.docx", "type": "docx"}, + map[string]interface{}{"token": "tok_link", "name": "link", "type": "shortcut"}, + }, + "has_more": false, + }, + }, + }) + + // Overwrite kept.txt. + 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": "tok_kept", + "version": "v2", + }, + }, + }) + + deleteStub := &httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/drive/v1/files/tok_orphan", + Body: map[string]interface{}{"code": 0, "msg": "ok"}, + } + reg.Register(deleteStub) + + err := mountAndRunDrive(t, DrivePush, []string{ + "+push", + "--local-dir", "local", + "--folder-token", "folder_root", + // This test exercises the overwrite path against kept.txt; opt + // into overwrite explicitly now that the default is "skip". + "--if-exists", "overwrite", + "--delete-remote", + "--yes", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + out := stdout.String() + if !strings.Contains(out, `"uploaded": 1`) { + t.Errorf("expected uploaded=1 (overwrite of kept.txt), got: %s", out) + } + if !strings.Contains(out, `"deleted_remote": 1`) { + t.Errorf("expected deleted_remote=1 (just orphan.txt), got: %s", out) + } + if strings.Contains(out, "notes.docx") { + t.Errorf("notes.docx must not appear in items (online docs are protected): %s", out) + } + // Bare-substring check (matches the notes.docx assertion above): + // drivePushItem serializes the field as "file_token", so a check + // against `"token": "tok_link"` would never fire — silent test + // failure to catch a regression. A bare substring is strictly + // stronger: it catches leaks into file_token, log lines, anywhere. + if strings.Contains(out, "tok_link") { + t.Errorf("shortcut tok_link must not appear in items: %s", out) + } + + // Registry.RoundTrip sets CapturedHeaders unconditionally on a match, + // so a non-nil value proves the DELETE for tok_orphan actually went + // through. If the test reached this point with no DELETE issued the + // registry would already have errored on the first uncovered request. + if deleteStub.CapturedHeaders == nil { + t.Fatal("DELETE for tok_orphan was never issued; --delete-remote did not run") + } +} + +// TestDrivePushRejectsAbsoluteLocalDir confirms SafeLocalFlagPath surfaces +// the proper flag name in the error message. +func TestDrivePushRejectsAbsoluteLocalDir(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + err := mountAndRunDrive(t, DrivePush, []string{ + "+push", + "--local-dir", "/etc", + "--folder-token", "folder_root", + "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected validation error for absolute --local-dir, got nil") + } + if !strings.Contains(err.Error(), "--local-dir") { + t.Fatalf("error must reference --local-dir, got: %v", err) + } +} + +// TestDrivePushRejectsBadIfExistsEnum verifies the framework's enum guard. +func TestDrivePushRejectsBadIfExistsEnum(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + err := mountAndRunDrive(t, DrivePush, []string{ + "+push", + "--local-dir", "local", + "--folder-token", "folder_root", + "--if-exists", "fail-and-die", + "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected enum validation error, got nil") + } + if !strings.Contains(err.Error(), "if-exists") { + t.Fatalf("error must reference --if-exists, got: %v", err) + } +} + +// TestDrivePushOverwriteWithoutVersionFails surfaces the protocol contract: +// when the upload_all response for an overwrite call lacks both `version` +// and `data_version`, the shortcut must report a structured api_error +// rather than silently report success. Important during the transitional +// period where the deployed backend may not yet expose the field. +func TestDrivePushOverwriteWithoutVersionFails(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join("local", "keep.txt"), []byte("local"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_keep", "name": "keep.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + // upload_all returns a NEW file_token but no version. Using a distinct + // value (tok_keep_new vs the entry's tok_keep) is what makes the test + // able to distinguish "items[].file_token comes from the upload_all + // response" from "fall back to entry.FileToken" — if the assertion + // stubbed the same token as the entry, a regression to the fallback + // branch would silently still pass. + 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": "tok_keep_new"}, + }, + }) + + err := mountAndRunDrive(t, DrivePush, []string{ + "+push", + "--local-dir", "local", + "--folder-token", "folder_root", + // Default is "skip" now; this test specifically exercises the + // overwrite-failure branch, so opt into overwrite explicitly. + "--if-exists", "overwrite", + "--as", "bot", + }, f, stdout) + // Item-level failures bump the exit code via output.ErrBare(ExitAPI), + // preserving the structured items[] envelope on stdout. Older behavior + // was to silently return nil; the assertion below pins the new contract. + if err == nil { + t.Fatalf("expected non-zero exit on item-level failure, got nil\nstdout: %s", stdout.String()) + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T: %v", err, err) + } + if exitErr.Code != output.ExitAPI { + t.Errorf("expected ExitAPI (%d), got code=%d", output.ExitAPI, exitErr.Code) + } + if exitErr.Detail != nil { + t.Errorf("ErrBare should carry no Detail (the items[] envelope already covered the per-item error), got: %#v", exitErr.Detail) + } + + out := stdout.String() + // summary.failed should reflect the missing version; summary.uploaded + // should not pretend the overwrite succeeded. + if !strings.Contains(out, `"failed": 1`) { + t.Errorf("expected failed=1, got: %s", out) + } + if !strings.Contains(out, "no version") { + t.Errorf("expected error about missing version in items[].error, got: %s", out) + } + // Pin the token-stability contract: the failed item must surface the + // token returned by upload_all (tok_keep_new), NOT the fallback + // entry.FileToken (tok_keep). Without this, a regression that always + // uses entry.FileToken on failure would slip through. + if !strings.Contains(out, `"file_token": "tok_keep_new"`) { + t.Errorf("expected failed item to surface upload_all's returned file_token (tok_keep_new), got: %s", out) + } +} + +// TestDrivePushOverwritePartialSuccessSurfacesReturnedToken pins the +// partial-success contract for upload_all: when the backend returns a +// non-zero `code` AND a non-empty `data.file_token`, the bytes have +// already landed under that returned token. The shortcut must surface +// THAT token in items[].file_token (not silently fall back to +// entry.FileToken via the empty-string sentinel), so callers always +// have a usable handle to whatever state Drive ended up in. +func TestDrivePushOverwritePartialSuccessSurfacesReturnedToken(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join("local", "keep.txt"), []byte("local"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_keep_old", "name": "keep.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + // upload_all returns a partial-success: non-zero code + a brand-new + // file_token. The shortcut must not drop that token. + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 1234, "msg": "partial-success simulated", + "data": map[string]interface{}{"file_token": "tok_keep_partial"}, + }, + }) + + err := mountAndRunDrive(t, DrivePush, []string{ + "+push", + "--local-dir", "local", + "--folder-token", "folder_root", + "--if-exists", "overwrite", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected non-zero exit on item-level failure, got nil\nstdout: %s", stdout.String()) + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Code != output.ExitAPI { + t.Fatalf("expected ExitAPI from output.ExitError, got %T %v", err, err) + } + + out := stdout.String() + if !strings.Contains(out, `"failed": 1`) { + t.Errorf("expected failed=1, got: %s", out) + } + // The freshly returned token must be the one in items[].file_token, + // not the stale entry.FileToken (tok_keep_old). + if !strings.Contains(out, `"file_token": "tok_keep_partial"`) { + t.Errorf("expected items[].file_token to surface upload_all's returned token (tok_keep_partial), got: %s", out) + } + if strings.Contains(out, `"file_token": "tok_keep_old"`) { + t.Errorf("must NOT fall back to entry.FileToken when upload_all returned a token; got: %s", out) + } +} + +// TestDrivePushSkipsDeleteAfterUploadFailure pins the half-sync safety +// guard: when any upload / overwrite / folder step fails, the +// --delete-remote phase must be skipped entirely. Otherwise a partial +// upload would proceed to delete remote orphans, leaving the tenant in +// a worse state than where it started (files missing locally and now +// also gone from Drive). +// +// Test setup mirrors the missing-version overwrite failure: one local +// file with a same-name remote that overwrites with no version field, +// plus one orphan remote file that --delete-remote --yes would +// otherwise delete. With the fix in place the orphan must NOT be +// reached. +func TestDrivePushSkipsDeleteAfterUploadFailure(t *testing.T) { + f, stdout, stderrBuf, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join("local", "keep.txt"), []byte("local"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_keep", "name": "keep.txt", "type": "file"}, + map[string]interface{}{"token": "tok_orphan", "name": "orphan.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + // Overwrite returns no version → fails. + 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": "tok_keep"}, + }, + }) + // Crucially: do NOT register a DELETE stub for tok_orphan. If the + // delete phase were still triggered after the upload failure, the + // registry's no-stub-or-die contract would surface an unmatched + // request and the test would catch the regression. + + err := mountAndRunDrive(t, DrivePush, []string{ + "+push", + "--local-dir", "local", + "--folder-token", "folder_root", + "--if-exists", "overwrite", + "--delete-remote", + "--yes", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected non-zero exit on overwrite failure, got nil\nstdout: %s", stdout.String()) + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Code != output.ExitAPI { + t.Fatalf("expected ExitAPI ExitError, got %v", err) + } + + out := stdout.String() + if !strings.Contains(out, `"failed": 1`) { + t.Errorf("expected failed=1 (overwrite missing-version), got: %s", out) + } + if !strings.Contains(out, `"deleted_remote": 0`) { + t.Errorf("expected deleted_remote=0 (delete phase must skip after upload failure), got: %s", out) + } + if strings.Contains(out, "tok_orphan") { + t.Errorf("orphan file token must not appear in items (delete phase skipped), got: %s", out) + } + // Operator-facing hint that cleanup was skipped lives on stderr. + if !strings.Contains(stderrBuf.String(), "Skipping --delete-remote") { + t.Errorf("expected stderr hint about skipped delete phase, got: %s", stderrBuf.String()) + } +} + +// TestDrivePushExitsZeroOnCleanRun pins the inverse: a successful run +// with no failures must NOT bump the exit code. Without this the +// ErrBare-on-failure path could regress to "always non-zero" silently. +func TestDrivePushExitsZeroOnCleanRun(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join("local", "a.txt"), []byte("AAA"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{"files": []interface{}{}, "has_more": false}, + }, + }) + 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": "tok_a"}, + }, + }) + + if err := mountAndRunDrive(t, DrivePush, []string{ + "+push", + "--local-dir", "local", + "--folder-token", "folder_root", + "--as", "bot", + }, f, stdout); err != nil { + t.Fatalf("expected nil error on clean run, got: %v\nstdout: %s", err, stdout.String()) + } + if !strings.Contains(stdout.String(), `"failed": 0`) { + t.Errorf("expected failed=0, got: %s", stdout.String()) + } +} + +// TestDrivePushUploadsSiblingWhenRemoteSameNameIsNativeDoc pins the +// behavior when a local regular file shares its rel_path with a Lark +// native cloud document on Drive (sheet/docx/bitable/...). +// +// The contract: +// - The native doc is NOT a candidate for overwrite — drivePushListRemote +// only puts type=file entries into the remoteFiles map, so the local +// "report" is treated as new and the upload_all body must NOT carry +// the sheet's token in the file_token form field (that would mean +// overwriting the sheet's bytes, not creating a sibling). +// - The native doc must NOT be deleted by --delete-remote --yes either. +// The orphan check iterates remoteFiles only; a sheet at the same +// rel_path as a missing local file would otherwise look orphaned. +// +// Both invariants together: native cloud docs are never reached by either +// the upload or the delete path, regardless of whether a same-named +// local file exists. +func TestDrivePushUploadsSiblingWhenRemoteSameNameIsNativeDoc(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join("local", "report"), []byte("local-csv-bytes"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + // Remote contains: + // - "report" as a Lark native sheet (type=sheet, tok_sheet) — must be + // left strictly alone + // - "minutes" as a native docx (type=docx, tok_docx) — no + // local counterpart, --delete-remote must skip it + // No type=file entries at all — every local rel_path is a "new upload" + // from drivePushListRemote's perspective. + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_sheet", "name": "report", "type": "sheet"}, + map[string]interface{}{"token": "tok_docx", "name": "minutes", "type": "docx"}, + }, + "has_more": false, + }, + }, + }) + + uploadStub := &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": "tok_report_file"}, + }, + } + reg.Register(uploadStub) + + err := mountAndRunDrive(t, DrivePush, []string{ + "+push", + "--local-dir", "local", + "--folder-token", "folder_root", + "--delete-remote", + "--yes", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + out := stdout.String() + if !strings.Contains(out, `"uploaded": 1`) { + t.Errorf("expected uploaded=1 (sibling create, not overwrite), got: %s", out) + } + // Native docs are never iterated for delete; --delete-remote must be 0. + if !strings.Contains(out, `"deleted_remote": 0`) { + t.Errorf("native docs must not be deleted, got: %s", out) + } + // Items must show the upload as a fresh "uploaded", not "overwritten", + // and must point at the new token, never at the sheet's token. + if !strings.Contains(out, `"action": "uploaded"`) { + t.Errorf("expected action=uploaded for sibling create, got: %s", out) + } + if !strings.Contains(out, `"file_token": "tok_report_file"`) { + t.Errorf("expected new file_token in items, got: %s", out) + } + if strings.Contains(out, "tok_sheet") { + t.Errorf("sheet token must not appear anywhere in output, got: %s", out) + } + if strings.Contains(out, "tok_docx") { + t.Errorf("docx token must not appear anywhere in output, got: %s", out) + } + + // The upload_all multipart body must NOT carry file_token. Carrying + // the sheet's token would mean asking the backend to overwrite the + // sheet's bytes with our local file's bytes — the exact bug this + // test guards against. file_name is "report" (no extension, matching + // the basename) and parent_node is the root folder. + body := decodeDriveMultipartBody(t, uploadStub) + if got, present := body.Fields["file_token"]; present { + t.Fatalf("upload_all body must NOT include file_token (would overwrite the sheet); got %q", got) + } + if got := body.Fields["file_name"]; got != "report" { + t.Fatalf("upload_all file_name = %q, want \"report\"", got) + } + if got := body.Fields["parent_node"]; got != "folder_root" { + t.Fatalf("upload_all parent_node = %q, want folder_root", got) + } +} + +// TestDrivePushReusesExistingRemoteFolder verifies that when a remote +// folder already exists at the target rel_path, the upload uses its +// pre-existing token instead of calling create_folder. Together with the +// "creates parents" test this pins the folderCache contract end-to-end. +func TestDrivePushReusesExistingRemoteFolder(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join("local", "sub", "b.txt"), []byte("BBB"), 0o644); err != nil { + t.Fatalf("WriteFile b: %v", err) + } + + // Root contains the folder "sub" already; recurse returns no files. + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "fld_existing_sub", "name": "sub", "type": "folder"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=fld_existing_sub", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{"files": []interface{}{}, "has_more": false}, + }, + }) + + uploadStub := &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": "tok_b"}, + }, + } + reg.Register(uploadStub) + + err := mountAndRunDrive(t, DrivePush, []string{ + "+push", + "--local-dir", "local", + "--folder-token", "folder_root", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + if !strings.Contains(stdout.String(), `"uploaded": 1`) { + t.Errorf("expected uploaded=1, got: %s", stdout.String()) + } + + // Upload must have been routed under the pre-existing sub-folder + // rather than re-creating it; the form's parent_node should be the + // remote folder token discovered in the listing. + body := decodeDriveMultipartBody(t, uploadStub) + if got := body.Fields["parent_node"]; got != "fld_existing_sub" { + t.Fatalf("upload parent_node = %q, want fld_existing_sub (folderCache miss?)", got) + } +} + +// TestDrivePushMirrorsEmptyDirectories confirms the gap codex review +// flagged: a local directory with no files inside must still surface on +// Drive as a created sub-folder, not be silently dropped because the +// upload loop only iterates regular files. +func TestDrivePushMirrorsEmptyDirectories(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll(filepath.Join("local", "empty"), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + // Empty remote. + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{"files": []interface{}{}, "has_more": false}, + }, + }) + + // create_folder for "empty" — must be issued even though no file + // will land underneath it. + createStub := &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_empty"}, + }, + } + reg.Register(createStub) + + err := mountAndRunDrive(t, DrivePush, []string{ + "+push", + "--local-dir", "local", + "--folder-token", "folder_root", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + out := stdout.String() + if !strings.Contains(out, `"action": "folder_created"`) { + t.Errorf("expected folder_created action for empty dir, got: %s", out) + } + if !strings.Contains(out, `"rel_path": "empty"`) { + t.Errorf("expected rel_path=empty in items, got: %s", out) + } + // Empty dirs do NOT bump summary.uploaded — that field is for files. + if !strings.Contains(out, `"uploaded": 0`) { + t.Errorf("uploaded should remain 0 for an empty-dir-only push, got: %s", out) + } + if createStub.CapturedHeaders == nil { + t.Fatal("create_folder for empty/ was never issued") + } +} + +// TestDrivePushUploadsLargeFileViaMultipart locks in the 3-step +// upload_prepare/upload_part/upload_finish flow used for files larger +// than common.MaxDriveMediaUploadSinglePartSize. The single shared file +// handle (opened once outside the part loop, reused via SectionReader) +// is implicitly exercised: the test asserts upload_part is invoked +// twice in a row before upload_finish, and would deadlock or surface a +// fileio open error if the helper still re-opened per chunk and the +// httpmock registry didn't have enough Open-permission stubs. +// +// The local file is created with Truncate to size+1 so it crosses the +// single-part threshold by one byte, exercising the "remaining < block +// size" tail path on the second chunk. +// +// Use a distinct AppID to avoid SDK token cache collisions with other +// tests (mirrors the existing TestDriveUploadLargeFileUsesMultipart +// pattern). +func TestDrivePushUploadsLargeFileViaMultipart(t *testing.T) { + pushTestConfig := &core.CliConfig{ + AppID: "drive-push-multipart-test", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, pushTestConfig) + + // Wrap the default FileIO provider to count Open calls. The + // shared-fd refactor opens the local file exactly once and feeds + // each block through io.NewSectionReader at distinct offsets; a + // regression back to "open per block" would bump this counter to + // block_num (2 in this test). + opens := &atomic.Int32{} + f.FileIOProvider = &countingOpenProvider{inner: f.FileIOProvider, opens: opens} + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + largePath := filepath.Join("local", "large.bin") + fh, err := os.Create(largePath) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := fh.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil { + t.Fatalf("Truncate: %v", err) + } + if err := fh.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + // Empty remote — large.bin is a fresh upload, not an overwrite. + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{"files": []interface{}{}, "has_more": false}, + }, + }) + + prepareStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_prepare", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "upload_id": "upid-large", + "block_size": float64(common.MaxDriveMediaUploadSinglePartSize), + "block_num": float64(2), + }, + }, + } + reg.Register(prepareStub) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_part", + Body: map[string]interface{}{"code": 0, "msg": "ok"}, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_part", + Body: map[string]interface{}{"code": 0, "msg": "ok"}, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_finish", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{"file_token": "tok_large"}, + }, + }) + + err = mountAndRunDrive(t, DrivePush, []string{ + "+push", + "--local-dir", "local", + "--folder-token", "folder_root", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + out := stdout.String() + if !strings.Contains(out, `"uploaded": 1`) { + t.Errorf("expected uploaded=1, got: %s", out) + } + if !strings.Contains(out, `"file_token": "tok_large"`) { + t.Errorf("expected file_token=tok_large in items, got: %s", out) + } + + // upload_prepare's body must NOT include file_token (this was a + // fresh upload, not an overwrite). Decoding the body proves both + // that the request was issued and that the conditional file_token + // branch in drivePushUploadMultipart was skipped correctly. + body := decodeCapturedJSONBody(t, prepareStub) + if _, present := body["file_token"]; present { + t.Fatalf("upload_prepare body must omit file_token for fresh uploads, got: %#v", body) + } + if got := body["parent_type"]; got != driveUploadParentTypeExplorer { + t.Fatalf("parent_type = %#v, want %q", got, driveUploadParentTypeExplorer) + } + if got := body["parent_node"]; got != "folder_root" { + t.Fatalf("parent_node = %#v, want folder_root", got) + } + + // One Open across the entire multipart upload — the load-bearing + // pin for the shared-fd refactor. block_num=2 above means a + // reopen-per-block regression would land at 2. + if got := opens.Load(); got != 1 { + t.Fatalf("FileIO.Open invocations = %d, want exactly 1 (single shared fd across all blocks)", got) + } +} + +// TestDrivePushHelpersRelPath pins the path utilities since the upload +// loop relies on slash-only rel_paths even on Windows. +func TestDrivePushHelpersRelPath(t *testing.T) { + t.Parallel() + + if got := drivePushParentRel("a.txt"); got != "" { + t.Fatalf("parent of root file should be empty, got %q", got) + } + if got := drivePushParentRel("a/b/c.txt"); got != "a/b" { + t.Fatalf("parent of a/b/c.txt = %q, want a/b", got) + } + if parent, name := drivePushSplitRel("a/b/c"); parent != "a/b" || name != "c" { + t.Fatalf("split a/b/c = (%q,%q), want (a/b,c)", parent, name) + } + if parent, name := drivePushSplitRel("solo"); parent != "" || name != "solo" { + t.Fatalf("split solo = (%q,%q), want (\"\",solo)", parent, name) + } + if got := drivePushJoinRel("", "x"); got != "x" { + t.Fatalf("joinRel(\"\", x) = %q, want x", got) + } + if got := drivePushJoinRel("a", "x"); got != "a/x" { + t.Fatalf("joinRel(a, x) = %q, want a/x", got) + } +} diff --git a/shortcuts/drive/shortcuts.go b/shortcuts/drive/shortcuts.go index 341b029cd..1214c04f7 100644 --- a/shortcuts/drive/shortcuts.go +++ b/shortcuts/drive/shortcuts.go @@ -19,6 +19,7 @@ func Shortcuts() []common.Shortcut { DriveMove, DriveDelete, DriveStatus, + DrivePush, DriveTaskResult, DriveApplyPermission, DriveSearch, diff --git a/shortcuts/drive/shortcuts_test.go b/shortcuts/drive/shortcuts_test.go index 97729dc0f..bec748aca 100644 --- a/shortcuts/drive/shortcuts_test.go +++ b/shortcuts/drive/shortcuts_test.go @@ -22,6 +22,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) { "+move", "+delete", "+status", + "+push", "+task_result", "+apply-permission", "+search", diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md index ed084c19f..9c195c97c 100644 --- a/skills/lark-drive/SKILL.md +++ b/skills/lark-drive/SKILL.md @@ -236,6 +236,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive + [flags]`) | [`+import`](references/lark-drive-import.md) | Import a local file to Drive as a cloud document (docx, sheet, bitable) | | [`+move`](references/lark-drive-move.md) | Move a file or folder to another location in Drive | | [`+delete`](references/lark-drive-delete.md) | Delete a Drive file or folder with limited polling for folder deletes | +| [`+push`](references/lark-drive-push.md) | Mirror a local directory onto a Drive folder (local → Drive). Supports `--if-exists` (overwrite/skip) and `--delete-remote` for one-way mirror sync; the destructive `--delete-remote` requires `--yes`. `--local-dir` is bounded to cwd by CLI path validation; tell the user to switch the agent's working directory if the source is outside cwd. | | [`+task_result`](references/lark-drive-task-result.md) | Poll async task result for import, export, move, or delete operations | | [`+apply-permission`](references/lark-drive-apply-permission.md) | Apply to the document owner for view/edit access (user-only; 5/day per document) | diff --git a/skills/lark-drive/references/lark-drive-push.md b/skills/lark-drive/references/lark-drive-push.md new file mode 100644 index 000000000..31fbb57e8 --- /dev/null +++ b/skills/lark-drive/references/lark-drive-push.md @@ -0,0 +1,137 @@ + +# drive +push + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +把本地目录**单向、文件级**镜像到飞书云空间的某个文件夹(本地 → Drive)。命令递归列出 `--folder-token` 下的远端清单,遍历 `--local-dir` 的所有常规文件,按相对路径在 Drive 上新建、覆盖或跳过;可选地(`--delete-remote --yes`)删除云端"本地没有"的 `type=file`。 + +> **"文件级镜像"≠"目录镜像"。** 命令只在文件维度收敛差异:本地多了文件就上传,本地少了文件且开了 `--delete-remote --yes` 就删远端文件。**远端只有的空目录、本地已删除的目录**都不会被收敛,云端目录树的多余结构不会被清理。如果需要"目录也要保持完全一致",得自行先 `+status` 找差异、再手动处理多余目录。 + +输出按"动作"分类: + +| 字段 | 含义 | +|------|------| +| `summary.uploaded` | 成功新建或覆盖的文件数 | +| `summary.skipped` | 按 `--if-exists=skip` 跳过的文件数 | +| `summary.failed` | 上传 / 覆盖 / 建目录 / 删除失败的条目数;**只要不为 0,命令就以非零状态退出**(结构化 `items[]` 仍在 stdout 上) | +| `summary.deleted_remote` | 启用 `--delete-remote --yes` 时删除的云端文件数 | +| `items[]` | 每个条目的明细(`rel_path` / `file_token` / `action` / 覆盖时的 `version` / `size_bytes` / 失败时的 `error`) | + +`items[].action` 取值:`uploaded` / `overwritten` / `skipped` / `folder_created` / `deleted_remote` / `failed` / `delete_failed`。 + +> 本地目录(包括空目录)会被镜像到 Drive;新建的子目录会以 `action: "folder_created"` 出现在 `items[]` 里,但**不计入** `summary.uploaded`(该字段只数文件)。已存在的远端目录复用其 token,不会重复 `create_folder`,也不会出现在 `items[]` 里。 + +## 命令 + +```bash +# 基础用法 —— 把本地 ./repo 增量推送到云端 fldcXXX +# 默认 --if-exists=skip:已经存在的远端文件保持不动,只新增、不覆盖。 +lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx + +# 显式覆盖远端同名文件(依赖 upload_all 的灰度协议字段,详见下文"覆盖语义") +lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \ + --if-exists overwrite + +# 文件级镜像同步:上传 / 覆盖 + 删除本地不存在的远端文件 +# (--delete-remote 必须搭配 --yes,否则会被 Validate 直接拒绝; +# 且 Validate 阶段会动态检查 space:document:delete scope,缺权限会立刻失败, +# 不会出现"上传成功了但是后面删除阶段挂了"的半同步状态) +lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \ + --if-exists overwrite --delete-remote --yes +``` + +## 参数 + +| 标志 | 必填 | 类型 | 说明 | +|------|------|------|------| +| `--local-dir` | 是 | path | 本地根目录(**必须是 cwd 的相对路径**;绝对路径或逃出 cwd 的相对路径会被 CLI 直接拒绝) | +| `--folder-token` | 是 | string | 目标 Drive 文件夹 token | +| `--if-exists` | 否 | enum | 远端文件已存在时的策略:`skip`(**默认**,安全)/ `overwrite`(依赖灰度后端协议,详见"覆盖语义") | +| `--delete-remote` | 否 | bool | 删除云端本地不存在的文件(文件级镜像;**不会**清理远端只有的目录);**必须配合 `--yes`**,且 Validate 阶段会动态检查 `space:document:delete` scope | +| `--yes` | 否 | bool | 确认 `--delete-remote`;不传时该破坏性操作在 Validate 阶段被拒绝 | + +## 上传与目录复刻范围 + +- **只上传 / 覆盖 / 删除 Drive `type=file`**。在线文档(`docx` / `sheet` / `bitable` / `mindnote` / `slides`)和快捷方式(`shortcut`)即使在同一 rel_path 下出现,也不会被覆盖或删除 —— 它们没有等价的本地二进制。 +- **本地目录结构整体被镜像**:所有子目录(含**空目录**)会按需在 Drive 上 `create_folder`;同名远端目录复用其 token,不重建。空目录不计入 `summary.uploaded`,但会在 `items[]` 里以 `folder_created` 形式留痕。 +- 已存在的远端文件按 `--if-exists` 决定 `overwrite` 还是 `skip`,没有第三种选择 —— 想做 `keep-both` 这类的请自行改名再 push。 + +## 覆盖语义 + +`--if-exists=overwrite` 走 `POST /open-apis/drive/v1/files/upload_all`,并在 form 中带上现有文件的 `file_token`,由后端原地更新内容并返回新版本号。`items[].version` 字段会回填该版本号。 + +> **为什么默认是 `skip` 而不是 `overwrite`:** `upload_all` 接受 `file_token` 字段、并在响应里返回 `version` 是设计文档(Drive 同步盘)规定的协议;此后端尚在灰度发布。在还未开通该字段的 tenant 上,`--if-exists=overwrite` 会因"无 version 返回"而把对应文件标成 `failed`,整次 `+push` 也会因此非零退出。所以默认值故意定为 `skip`:第一次往一个已经有内容的目录里 push,不会因为协议没到位就把整次运行打挂;要真的覆盖远端,必须显式带 `--if-exists overwrite`。新建上传不依赖该字段,不受影响。 + +大文件(>20MB)会自动切到三段式 `upload_prepare` / `upload_part` / `upload_finish`;该路径下 `version` 暂未在响应中返回,覆盖结果中 `items[].version` 会留空,但 `file_token` 与 `action: overwritten` 仍会正确产出。 + +## --delete-remote 的安全行为 + +`--delete-remote` 是命令里**唯一的破坏性 flag**,会按"远端有但本地没有"逐个 `DELETE /open-apis/drive/v1/files/?type=file` 清理云端副本。设计上把它跟 `--yes` 强绑定: + +- `--delete-remote`(无 `--yes`)→ Validate 直接报错:`--delete-remote requires --yes`,不会发起任何列表 / 上传 / 删除请求。 +- `--delete-remote --yes` → Validate 阶段还会**动态做一次** `space:document:delete` 的 scope 预检:缺这条 scope 时整次运行立刻失败、不发任何上传请求,避免出现"上传都成功了,但删除阶段才报 missing_scope"的半同步状态。 +- `--delete-remote --yes`(且 scope 已授权)→ 正常执行:先把本地文件 push 上去,再扫一遍远端 `type=file` 列表,把不在本地清单里的逐个删除。**任何上传 / 覆盖 / 建目录失败时,整段 `--delete-remote` 阶段会被跳过**(stderr 上有提示),命令以非零状态退出,远端不会被破坏。 +- 不传 `--delete-remote` → `summary.deleted_remote` 永远是 0;命令对远端"多余"文件视而不见。 +- 在线文档(docx / sheet / bitable / ...)和快捷方式即使本地完全没有同名文件,也**不会**进入删除候选,因为它们从来不进 `summary.uploaded` 的对齐域。 +- **远端只有的空目录、本地已删除的目录**也不会被清理 —— 这是"文件级镜像"的语义边界,命令不会对目录结构做主动收敛。 + +第 6 章里把 `+push --delete-remote` 标了 `high-risk-write`,CLI 这边的实现等价于"未传 `--yes` 时拒绝执行 + 动态 scope 预检",符合该约束的精神。 + +## 输出 schema + +```json +{ + "summary": { + "uploaded": 0, + "skipped": 0, + "failed": 0, + "deleted_remote": 0 + }, + "items": [ + {"rel_path": "...", "file_token": "...", "action": "folder_created"}, + {"rel_path": "...", "file_token": "...", "action": "uploaded", "size_bytes": 0}, + {"rel_path": "...", "file_token": "...", "action": "overwritten", "version": "...", "size_bytes": 0}, + {"rel_path": "...", "file_token": "...", "action": "skipped", "size_bytes": 0}, + {"rel_path": "...", "action": "failed", "size_bytes": 0, "error": "..."}, + {"rel_path": "...", "file_token": "...", "action": "deleted_remote"}, + {"rel_path": "...", "file_token": "...", "action": "delete_failed", "error": "..."} + ] +} +``` + +`rel_path` 始终用 `/` 作为分隔符(跨平台一致)。 + +## 性能注意 + +- 上传流量 ≈ 本地待上传文件的总字节数。push 是**全量**上传 —— 跟 `+status` 不一样,不会按 hash 跳过"内容相同"的文件(status 是按 hash 比较,push 是按 `--if-exists`),所以一次跑可能很重。 +- 想避免重跑全量,可以先 `+status` 找出 `new_local` 和 `modified`,再只对这些文件单独上传 / 覆盖。 +- 大文件会用三段式分片上传(不会把整个 body 读进内存),但本地磁盘和上行带宽需要够。 + +## 所需 scope + +| 操作 | scope | 是否在命令上预声明 | +|------|-------|-------------------| +| 列出文件夹 / 子目录 | `drive:drive.metadata:readonly` | ✅ 预声明 | +| 上传 / 覆盖文件 | `drive:file:upload` | ✅ 预声明 | +| 新建子目录(`create_folder`) | `space:folder:create` | ✅ 预声明 | +| 删除文件(仅 `--delete-remote --yes`) | `space:document:delete` | ⚙️ 不在命令默认 Scopes 里,但在 `--delete-remote --yes` 时由 Validate 动态预检 | + +`drive:drive` 在部分企业被策略禁用,所以 +push 故意只声明上面这几条细粒度 scope。 + +> **关于 `space:document:delete`:** 框架的 scope 预检(`runner.go: checkShortcutScopes`)会在 `Validate` 和 `--dry-run` 之前就把命令上声明的 scope 全检查一遍;如果把删除 scope 也预声明,**普通上传或 dry-run** 都会因为没授权删除权限而被拦下来。所以这一项不放在命令的默认 Scopes 里,而是在 Validate 中**条件触发**:只有 `--delete-remote --yes` 同时打开时才会调用 `runtime.EnsureScopes([]string{"space:document:delete"})` 做一次动态前置校验。这样既保留了"普通上传不需要删除权限"的便利,又能在真要做镜像删除前把 scope 缺失暴露出来,避免出现"上传成功 → 删除阶段才挂"的半同步状态。 +> +> 想一次性把权限补齐:`lark-cli auth login --scope "drive:drive.metadata:readonly drive:file:upload space:folder:create space:document:delete"`。 + +## 范围限制 + +`--local-dir` 只接受 cwd 内的相对路径。CLI 会先 `EvalSymlinks` 整条路径,再判断它是否仍落在 cwd 内 —— **指向 cwd 外的符号链接也会被拒**,"在 cwd 内放一条软链指向外面" 这条捷径走不通,会直接撞上 `unsafe file path`。 + +如果用户想 push cwd 之外的目录,**不要 agent 自己 `cd` 绕过**。可以选:让用户在外部把 agent 工作目录切换到目标的祖先后重启会话;或者把目标整体物理移动 / 拷贝到 cwd 内(不是软链);或者直接放弃这次同步,改用别的方式。 + +## 参考 + +- [lark-drive](../SKILL.md) —— 云空间全部命令 +- [lark-shared](../../lark-shared/SKILL.md) —— 认证和全局参数 +- [lark-drive-status](lark-drive-status.md) —— 上传前先看差异(避免全量回写) +- [lark-drive-pull](lark-drive-pull.md) —— Drive → 本地的对称命令 +- [lark-drive-upload](lark-drive-upload.md) —— 单文件按需上传 diff --git a/tests/cli_e2e/drive/drive_push_dryrun_test.go b/tests/cli_e2e/drive/drive_push_dryrun_test.go new file mode 100644 index 000000000..af8072015 --- /dev/null +++ b/tests/cli_e2e/drive/drive_push_dryrun_test.go @@ -0,0 +1,243 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestDrive_PushDryRun locks in the request shape the +push shortcut emits +// under --dry-run: the real CLI binary is invoked end-to-end, so flag +// parsing, Validate (still runs in dry-run mode), and the dry-run renderer +// all execute. The printed envelope is then inspected for GET method, +// list-files URL, the folder_token parameter, and key phrases from Desc. +// +// Fake credentials are sufficient because --dry-run short-circuits before +// any real network call. +func TestDrive_PushDryRun(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + workDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+push", + "--local-dir", "local", + "--folder-token", "fldcnE2E001", + "--dry-run", + }, + WorkDir: workDir, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + if got := gjson.Get(out, "api.0.method").String(); got != "GET" { + t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files" { + t.Fatalf("url = %q, want /open-apis/drive/v1/files\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" { + t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out) + } + desc := gjson.Get(out, "description").String() + if !strings.Contains(desc, "list --folder-token") { + t.Fatalf("description missing list phrase, got %q\nstdout:\n%s", desc, out) + } + if !strings.Contains(desc, "upload") { + t.Fatalf("description missing upload phrase, got %q\nstdout:\n%s", desc, out) + } +} + +// TestDrive_PushDryRunRejectsAbsoluteLocalDir confirms the path validator +// runs in the real binary's Validate stage and surfaces a structured error +// referencing --local-dir. +func TestDrive_PushDryRunRejectsAbsoluteLocalDir(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+push", + "--local-dir", "/etc", + "--folder-token", "fldcnE2E001", + "--dry-run", + }, + WorkDir: t.TempDir(), + DefaultAs: "user", + }) + require.NoError(t, err) + // Validate-stage rejection emits ExitValidation (2). A regression + // that reclassified this as a generic api_error (1) or success (0) + // would slip through a loose `!= 0` check, so assert the exact code. + if result.ExitCode != 2 { + t.Fatalf("absolute --local-dir must be rejected with exit=2 (Validate), got exit=%d\nstdout:\n%s\nstderr:\n%s", result.ExitCode, result.Stdout, result.Stderr) + } + combined := result.Stdout + "\n" + result.Stderr + if !strings.Contains(combined, "--local-dir") { + t.Fatalf("expected --local-dir in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) + } +} + +// TestDrive_PushDryRunRejectsDeleteRemoteWithoutYes locks in the safety +// guard: --delete-remote without --yes must be refused upfront, even +// under --dry-run, so an unintended delete flag never silently slides +// through. +func TestDrive_PushDryRunRejectsDeleteRemoteWithoutYes(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + workDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+push", + "--local-dir", "local", + "--folder-token", "fldcnE2E001", + "--delete-remote", + "--dry-run", + }, + WorkDir: workDir, + DefaultAs: "user", + }) + require.NoError(t, err) + // Same exact-code reasoning as the absolute-path test: this is a + // Validate-stage rejection so it must surface as ExitValidation (2). + if result.ExitCode != 2 { + t.Fatalf("--delete-remote without --yes must be rejected with exit=2 (Validate), got exit=%d\nstdout:\n%s\nstderr:\n%s", result.ExitCode, result.Stdout, result.Stderr) + } + combined := result.Stdout + "\n" + result.Stderr + if !strings.Contains(combined, "--yes") { + t.Fatalf("expected --yes hint in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) + } +} + +// TestDrive_PushDryRunAcceptsDeleteRemoteWithYes is the symmetric guard +// to TestDrive_PushDryRunRejectsDeleteRemoteWithoutYes: when --yes is +// passed alongside --delete-remote, Validate must accept the run and +// hand off to the dry-run renderer. +// +// Specifically pins the conditional scope pre-check added to Validate: +// when the resolver has no token / no scope metadata (the e2e setup +// uses fake credentials with no real auth state), runtime.EnsureScopes +// is a silent no-op so dry-run still emits its envelope. A regression +// where the pre-check incorrectly fired against an empty scope list +// would surface here as a non-zero exit and a missing_scope error. +func TestDrive_PushDryRunAcceptsDeleteRemoteWithYes(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + workDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+push", + "--local-dir", "local", + "--folder-token", "fldcnE2E001", + "--delete-remote", + "--yes", + "--dry-run", + }, + WorkDir: workDir, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + if got := gjson.Get(out, "api.0.method").String(); got != "GET" { + t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" { + t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out) + } + // No structured error envelope on stdout/stderr — the conditional + // EnsureScopes call must not trip a missing_scope here. + if strings.Contains(out, `"type": "missing_scope"`) || strings.Contains(result.Stderr, "missing_scope") { + t.Fatalf("conditional scope pre-check fired in a no-credential env\nstdout:\n%s\nstderr:\n%s", out, result.Stderr) + } +} + +// TestDrive_PushDryRunRejectsMissingFolderToken confirms cobra's +// required-flag enforcement runs before our custom Validate. +func TestDrive_PushDryRunRejectsMissingFolderToken(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + workDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+push", + "--local-dir", "local", + "--dry-run", + }, + WorkDir: workDir, + DefaultAs: "user", + }) + require.NoError(t, err) + // This is a cobra-level required-flag check that fires BEFORE our + // Validate callback, so the exit code is cobra's generic flag-error + // (1) — distinct from ExitValidation (2). Asserting the exact code + // pins which layer rejected the run, which matters because a + // regression that pushed required-flag validation into our own + // Validate (changing the exit class to 2) would silently slip + // through a loose `!= 0` check. + if result.ExitCode != 1 { + t.Fatalf("missing --folder-token must be rejected with exit=1 (cobra required-flag), got exit=%d\nstdout:\n%s\nstderr:\n%s", result.ExitCode, result.Stdout, result.Stderr) + } + combined := result.Stdout + "\n" + result.Stderr + if !strings.Contains(combined, "folder-token") { + t.Fatalf("expected folder-token in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) + } +}