From e09175d07ea79245b95a52d2df52aa137b7f73f3 Mon Sep 17 00:00:00 2001 From: Jason Chiu Date: Fri, 24 Apr 2026 09:51:50 -0700 Subject: [PATCH 1/8] feat: add inferDownloadExtension and getDefaultDownloadPath utilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add shared utilities for inferring file extensions from object content types when generating default download paths. This is the complement of adjustFileExtension() in runloop-fe (PR #1714), which strips extensions for mount paths — this module adds extensions for download paths. Rules: - text/binary with no suffix → .txt/.bin - gzip + .tar suffix → .tgz (not .tar.gz) - gzip/tar/tgz with mismatched suffix → append correct extension - All suffix checks are case-insensitive Co-Authored-By: Claude Opus 4.6 --- src/utils/downloadPath.ts | 107 ++++++++++ tests/__tests__/utils/downloadPath.test.ts | 219 +++++++++++++++++++++ 2 files changed, 326 insertions(+) create mode 100644 src/utils/downloadPath.ts create mode 100644 tests/__tests__/utils/downloadPath.test.ts diff --git a/src/utils/downloadPath.ts b/src/utils/downloadPath.ts new file mode 100644 index 00000000..75b15705 --- /dev/null +++ b/src/utils/downloadPath.ts @@ -0,0 +1,107 @@ +/** + * Utilities for inferring download file extensions from content types + * and generating default download paths for objects. + * + * This is the complement of adjustFileExtension() in runloop-fe's + * object-mount-utils.ts, which strips extensions for mount paths + * (after decompression/extraction). This module adds extensions for + * download paths so the saved file reflects the object's content type. + */ + +/** Suffixes considered "already gzip" (case-insensitive) */ +const GZIP_SUFFIXES = new Set([".gz", ".gzip", ".taz", ".tgz"]); + +/** Suffixes considered "already tgz" (case-insensitive) */ +const TGZ_SUFFIXES = new Set([".taz", ".tgz"]); + +/** + * Check if name ends with a compound suffix like .tar.gz or .tar.gzip + * (case-insensitive). Returns true if the last two dot-segments match. + */ +function hasCompoundTgzSuffix(name: string): boolean { + return /\.(tar\.gz|tar\.gzip)$/i.test(name); +} + +/** + * Get the suffix of a filename (the part after the last dot). + * Returns empty string if no dot or only a leading dot (e.g. ".hidden"). + */ +function getSuffix(name: string): string { + const lastDot = name.lastIndexOf("."); + if (lastDot <= 0) return ""; + return name.slice(lastDot); +} + +/** + * Returns true if the name has any dot-separated extension. + * A leading dot alone (e.g. ".hidden") does not count as having a suffix. + */ +function hasSuffix(name: string): boolean { + return getSuffix(name) !== ""; +} + +/** + * Infer a download filename by appending or adjusting the file extension + * based on the object's content_type. + * + * Rules (suffix comparisons are case-insensitive): + * - text + no suffix → .txt + * - binary + no suffix → .bin + * - gzip + suffix is .tar → replace with .tgz + * - gzip + suffix not in {.gz,.gzip,.taz,.tgz,.tar} → append .gz + * - tar + suffix != .tar → append .tar + * - tgz + suffix not in {.tar.gz,.tar.gzip,.taz,.tgz} → append .tgz + * - unspecified / undefined → no change + */ +export function inferDownloadExtension( + name: string, + contentType: string | undefined, +): string { + if (!contentType || contentType === "unspecified") return name; + + const suffix = getSuffix(name).toLowerCase(); + + switch (contentType) { + case "text": + return hasSuffix(name) ? name : `${name}.txt`; + + case "binary": + return hasSuffix(name) ? name : `${name}.bin`; + + case "gzip": + if (suffix === ".tar") { + // gzipped tar → .tgz + return name.slice(0, -suffix.length) + ".tgz"; + } + if (GZIP_SUFFIXES.has(suffix)) return name; + return `${name}.gz`; + + case "tar": + if (suffix === ".tar") return name; + return `${name}.tar`; + + case "tgz": + if (hasCompoundTgzSuffix(name)) return name; + if (TGZ_SUFFIXES.has(suffix)) return name; + return `${name}.tgz`; + + default: + return name; + } +} + +/** + * Generate a default download path for an object. + * + * Uses the object's name (or ID as fallback), applies extension inference, + * and prepends "./" for a relative path. + */ +export function getDefaultDownloadPath( + name: string | undefined, + id: string, + contentType: string | undefined, +): string { + const baseName = name?.trim() || id; + const withExtension = inferDownloadExtension(baseName, contentType); + return `./${withExtension}`; +} diff --git a/tests/__tests__/utils/downloadPath.test.ts b/tests/__tests__/utils/downloadPath.test.ts new file mode 100644 index 00000000..0028fc3f --- /dev/null +++ b/tests/__tests__/utils/downloadPath.test.ts @@ -0,0 +1,219 @@ +/** + * Tests for download path utilities + */ + +import { describe, it, expect } from "@jest/globals"; +import { + inferDownloadExtension, + getDefaultDownloadPath, +} from "../../../src/utils/downloadPath.js"; + +describe("inferDownloadExtension", () => { + describe("no suffix on name", () => { + it("appends .txt for text content type", () => { + expect(inferDownloadExtension("myfile", "text")).toBe("myfile.txt"); + }); + + it("appends .bin for binary content type", () => { + expect(inferDownloadExtension("myfile", "binary")).toBe("myfile.bin"); + }); + + it("appends .gz for gzip content type", () => { + expect(inferDownloadExtension("myfile", "gzip")).toBe("myfile.gz"); + }); + + it("appends .tar for tar content type", () => { + expect(inferDownloadExtension("myfile", "tar")).toBe("myfile.tar"); + }); + + it("appends .tgz for tgz content type", () => { + expect(inferDownloadExtension("myfile", "tgz")).toBe("myfile.tgz"); + }); + + it("appends .txt for dot-only hidden files with text type", () => { + expect(inferDownloadExtension(".hidden", "text")).toBe(".hidden.txt"); + }); + }); + + describe("suffix matches content type (no change)", () => { + it("keeps .gz for gzip", () => { + expect(inferDownloadExtension("myfile.gz", "gzip")).toBe("myfile.gz"); + }); + + it("keeps .gzip for gzip", () => { + expect(inferDownloadExtension("myfile.gzip", "gzip")).toBe( + "myfile.gzip", + ); + }); + + it("keeps .taz for gzip", () => { + expect(inferDownloadExtension("file.taz", "gzip")).toBe("file.taz"); + }); + + it("keeps .tgz for gzip", () => { + expect(inferDownloadExtension("file.tgz", "gzip")).toBe("file.tgz"); + }); + + it("keeps .tar for tar", () => { + expect(inferDownloadExtension("myfile.tar", "tar")).toBe("myfile.tar"); + }); + + it("keeps .tgz for tgz", () => { + expect(inferDownloadExtension("myfile.tgz", "tgz")).toBe("myfile.tgz"); + }); + + it("keeps .taz for tgz", () => { + expect(inferDownloadExtension("myfile.taz", "tgz")).toBe("myfile.taz"); + }); + + it("keeps .tar.gz for tgz", () => { + expect(inferDownloadExtension("myfile.tar.gz", "tgz")).toBe( + "myfile.tar.gz", + ); + }); + + it("keeps .tar.gzip for tgz", () => { + expect(inferDownloadExtension("data.tar.gzip", "tgz")).toBe( + "data.tar.gzip", + ); + }); + }); + + describe("suffix mismatches content type (appends)", () => { + it("appends .gz for gzip when suffix is .txt", () => { + expect(inferDownloadExtension("myfile.txt", "gzip")).toBe( + "myfile.txt.gz", + ); + }); + + it("appends .tar for tar when suffix is .json", () => { + expect(inferDownloadExtension("myfile.json", "tar")).toBe( + "myfile.json.tar", + ); + }); + + it("appends .tgz for tgz when suffix is .bin", () => { + expect(inferDownloadExtension("myfile.bin", "tgz")).toBe( + "myfile.bin.tgz", + ); + }); + }); + + describe("gzip + .tar special case", () => { + it("replaces .tar with .tgz for gzip content type", () => { + expect(inferDownloadExtension("archive.tar", "gzip")).toBe( + "archive.tgz", + ); + }); + + it("replaces .TAR with .tgz for gzip content type (case-insensitive)", () => { + expect(inferDownloadExtension("archive.TAR", "gzip")).toBe( + "archive.tgz", + ); + }); + }); + + describe("text/binary with existing suffix (no change)", () => { + it("keeps .json for text type", () => { + expect(inferDownloadExtension("myfile.json", "text")).toBe("myfile.json"); + }); + + it("keeps .yaml for text type", () => { + expect(inferDownloadExtension("config.yaml", "text")).toBe("config.yaml"); + }); + + it("keeps .wasm for binary type", () => { + expect(inferDownloadExtension("myfile.wasm", "binary")).toBe( + "myfile.wasm", + ); + }); + + it("keeps .exe for binary type", () => { + expect(inferDownloadExtension("app.exe", "binary")).toBe("app.exe"); + }); + }); + + describe("case insensitivity", () => { + it("recognizes .GZ as matching gzip", () => { + expect(inferDownloadExtension("myfile.GZ", "gzip")).toBe("myfile.GZ"); + }); + + it("recognizes .TAR as matching tar", () => { + expect(inferDownloadExtension("myfile.TAR", "tar")).toBe("myfile.TAR"); + }); + + it("recognizes .Tgz as matching tgz", () => { + expect(inferDownloadExtension("myfile.Tgz", "tgz")).toBe("myfile.Tgz"); + }); + + it("recognizes .TAR.GZIP as matching tgz", () => { + expect(inferDownloadExtension("data.TAR.GZIP", "tgz")).toBe( + "data.TAR.GZIP", + ); + }); + + it("recognizes .TAR.GZ as matching tgz", () => { + expect(inferDownloadExtension("data.TAR.GZ", "tgz")).toBe("data.TAR.GZ"); + }); + + it("recognizes .Taz as matching tgz", () => { + expect(inferDownloadExtension("data.Taz", "tgz")).toBe("data.Taz"); + }); + }); + + describe("edge cases", () => { + it("returns name unchanged for unspecified content type", () => { + expect(inferDownloadExtension("myfile", "unspecified")).toBe("myfile"); + }); + + it("returns name unchanged for undefined content type", () => { + expect(inferDownloadExtension("myfile", undefined)).toBe("myfile"); + }); + + it("returns name unchanged for empty string content type", () => { + expect(inferDownloadExtension("myfile", "")).toBe("myfile"); + }); + }); +}); + +describe("getDefaultDownloadPath", () => { + it("uses name with extension inference", () => { + expect(getDefaultDownloadPath("myfile", "obj_123", "text")).toBe( + "./myfile.txt", + ); + }); + + it("falls back to id when name is undefined", () => { + expect(getDefaultDownloadPath(undefined, "obj_123", "text")).toBe( + "./obj_123.txt", + ); + }); + + it("falls back to id when name is empty", () => { + expect(getDefaultDownloadPath("", "obj_123", "tar")).toBe("./obj_123.tar"); + }); + + it("falls back to id when name is whitespace", () => { + expect(getDefaultDownloadPath(" ", "obj_123", "binary")).toBe( + "./obj_123.bin", + ); + }); + + it("trims name before processing", () => { + expect(getDefaultDownloadPath(" myfile ", "obj_123", "gzip")).toBe( + "./myfile.gz", + ); + }); + + it("preserves existing matching extension", () => { + expect(getDefaultDownloadPath("data.tar.gz", "obj_123", "tgz")).toBe( + "./data.tar.gz", + ); + }); + + it("handles no content type", () => { + expect(getDefaultDownloadPath("myfile", "obj_123", undefined)).toBe( + "./myfile", + ); + }); +}); From 7bb6103aa2dff172f55ae516e8ceb8b06e3c8adc Mon Sep 17 00:00:00 2001 From: Jason Chiu Date: Fri, 24 Apr 2026 09:57:15 -0700 Subject: [PATCH 2/8] feat: make download path optional with stdout support - Change `object download ` to `object download [path]` - When path is omitted, auto-resolve using object name/content_type via the new getDefaultDownloadPath utility - When path is `-`, write downloaded data to stdout - Warn on stderr when writing binary content types to a TTY - Structured output (-o json) goes to stderr when using stdout mode Co-Authored-By: Claude Opus 4.6 --- src/commands/object/download.ts | 61 +++++++++++++++++++++++++++++---- src/utils/commands.ts | 6 ++-- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/src/commands/object/download.ts b/src/commands/object/download.ts index b2289fc5..2a311322 100644 --- a/src/commands/object/download.ts +++ b/src/commands/object/download.ts @@ -5,18 +5,46 @@ import { writeFile } from "fs/promises"; import { getClient } from "../../utils/client.js"; import { output, outputError } from "../../utils/output.js"; +import { getDefaultDownloadPath } from "../../utils/downloadPath.js"; +import { processUtils } from "../../utils/processUtils.js"; interface DownloadObjectOptions { id: string; - path: string; + path?: string; extract?: boolean; durationSeconds?: number; output?: string; } +/** Content types that produce non-text (binary) output */ +const BINARY_CONTENT_TYPES = new Set([ + "binary", + "gzip", + "tar", + "tgz", + "unspecified", +]); + export async function downloadObject(options: DownloadObjectOptions) { try { const client = getClient(); + const isStdout = options.path === "-"; + + // Resolve the download path when not provided or when writing to stdout + // (stdout mode still needs content_type for the TTY binary warning) + let resolvedPath = options.path; + let contentType: string | undefined; + if (!resolvedPath || isStdout) { + const obj = await client.objects.retrieve(options.id); + contentType = obj.content_type; + if (!resolvedPath) { + resolvedPath = getDefaultDownloadPath( + obj.name, + obj.id, + obj.content_type, + ); + } + } // Get the download URL const downloadUrlResponse = await client.objects.download(options.id, { @@ -32,19 +60,40 @@ export async function downloadObject(options: DownloadObjectOptions) { // Save the file const arrayBuffer = await response.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); - await writeFile(options.path, buffer); + + if (isStdout) { + // Warn if writing binary data to a terminal + if ( + processUtils.stdout.isTTY && + contentType && + BINARY_CONTENT_TYPES.has(contentType) + ) { + processUtils.stderr.write( + "Warning: writing binary data to terminal; pipe to a file or command instead\n", + ); + } + // Raw process.stdout for binary data (processUtils.stdout.write only accepts string) + process.stdout.write(buffer); + } else { + await writeFile(resolvedPath, buffer); + } // TODO: Handle extraction if requested (options.extract) const result = { id: options.id, - path: options.path, + path: isStdout ? "-" : resolvedPath, extracted: options.extract || false, }; - // Default: just output the local path for easy scripting - if (!options.output || options.output === "text") { - console.log(options.path); + if (isStdout) { + // Structured output goes to stderr to avoid mixing with data (always JSON) + if (options.output && options.output !== "text") { + processUtils.stderr.write(JSON.stringify(result, null, 2) + "\n"); + } + } else if (!options.output || options.output === "text") { + // Default: just output the local path for easy scripting + console.log(resolvedPath); } else { output(result, { format: options.output, defaultFormat: "json" }); } diff --git a/src/utils/commands.ts b/src/utils/commands.ts index ff9bd0e6..8e7ee83b 100644 --- a/src/utils/commands.ts +++ b/src/utils/commands.ts @@ -630,8 +630,10 @@ export function createProgram(): Command { }); object - .command("download ") - .description("Download object to local file") + .command("download [path]") + .description( + "Download object to local file (path defaults to ./ with inferred extension; use - for stdout)", + ) .option("--extract", "Extract downloaded archive after download") .option( "--duration-seconds ", From 8e6f7f554c282318d4c46111d87f96cb70563e6e Mon Sep 17 00:00:00 2001 From: Jason Chiu Date: Fri, 24 Apr 2026 10:04:43 -0700 Subject: [PATCH 3/8] feat: support stdin for object upload via `-` path - When path is `-`, read upload data from stdin - Require --name and --content-type for stdin uploads since they cannot be inferred from a filename - Update command description to document stdin support Co-Authored-By: Claude Opus 4.6 --- src/commands/object/upload.ts | 132 +++++++++++++++++++++------------- src/utils/commands.ts | 4 +- 2 files changed, 85 insertions(+), 51 deletions(-) diff --git a/src/commands/object/upload.ts b/src/commands/object/upload.ts index b5f4fde1..34280c9d 100644 --- a/src/commands/object/upload.ts +++ b/src/commands/object/upload.ts @@ -161,6 +161,14 @@ export async function createTarBuffer( return Buffer.from(data); } +async function readStdinBuffer(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks); +} + export async function uploadObject(options: UploadObjectOptions) { try { const client = getClient(); @@ -171,69 +179,95 @@ export async function uploadObject(options: UploadObjectOptions) { return; } - // Validate all paths exist (use lstat to match collectEntries and detect symlinks) - // Key by resolved absolute path so collectEntries can reuse stats - const statsMap = new Map>>(); - for (const p of paths) { - try { - const s = await lstat(p); - if (s.isSymbolicLink()) { - outputError( - `Path is a symlink: ${p}. Resolve the symlink or pass the target path directly.`, - ); - return; - } - statsMap.set(resolve(p), s); - } catch { - outputError(`Path does not exist: ${p}`); - return; - } - } + const hasStdin = paths.includes("-"); + const isStdin = paths.length === 1 && hasStdin; - const isTarType = contentType === "tar" || contentType === "tgz"; - const isSinglePath = paths.length === 1; - const firstStats = isSinglePath - ? statsMap.get(resolve(paths[0]))! - : undefined; - const singleIsDir = isSinglePath && firstStats!.isDirectory(); - - // Multi-path requires tar/tgz content type - if (paths.length > 1 && !isTarType) { + // stdin cannot be mixed with other paths (e.g. `upload - file1.txt`) + if (hasStdin && !isStdin) { outputError( - "Multiple paths require --content-type tar or --content-type tgz", + "Cannot mix stdin (-) with other paths. Use - alone or provide only file/directory paths.", ); - return; } - // Directory without tar/tgz type - if (singleIsDir && !isTarType) { - outputError( - "Cannot upload a directory directly. Use --content-type tar or --content-type tgz to create an archive.", - ); - return; + if (isStdin) { + if (!name) { + outputError("--name is required when uploading from stdin"); + } + if (!contentType) { + outputError("--content-type is required when uploading from stdin"); + } } let fileBuffer: Buffer; let detectedContentType: ContentType; let fileSize: number; - const shouldCreateArchive = isTarType && (paths.length > 1 || singleIsDir); - - if (shouldCreateArchive) { - const gzip = contentType === "tgz"; - fileBuffer = await createTarBuffer(paths, gzip, statsMap); - detectedContentType = contentType as ContentType; + if (isStdin) { + fileBuffer = await readStdinBuffer(); fileSize = fileBuffer.length; + detectedContentType = contentType as ContentType; } else { - // Single file upload (existing behavior) - const filePath = paths[0]; - fileBuffer = await readFile(filePath); - fileSize = fileBuffer.length; + // Validate all paths exist (use lstat to match collectEntries and detect symlinks) + // Key by resolved absolute path so collectEntries can reuse stats + const statsMap = new Map>>(); + for (const p of paths) { + try { + const s = await lstat(p); + if (s.isSymbolicLink()) { + outputError( + `Path is a symlink: ${p}. Resolve the symlink or pass the target path directly.`, + ); + return; + } + statsMap.set(resolve(p), s); + } catch { + outputError(`Path does not exist: ${p}`); + return; + } + } - detectedContentType = contentType as ContentType; - if (!detectedContentType) { - const ext = extname(filePath).toLowerCase(); - detectedContentType = CONTENT_TYPE_MAP[ext] || "unspecified"; + const isTarType = contentType === "tar" || contentType === "tgz"; + const isSinglePath = paths.length === 1; + const firstStats = isSinglePath + ? statsMap.get(resolve(paths[0]))! + : undefined; + const singleIsDir = isSinglePath && firstStats!.isDirectory(); + + // Multi-path requires tar/tgz content type + if (paths.length > 1 && !isTarType) { + outputError( + "Multiple paths require --content-type tar or --content-type tgz", + ); + return; + } + + // Directory without tar/tgz type + if (singleIsDir && !isTarType) { + outputError( + "Cannot upload a directory directly. Use --content-type tar or --content-type tgz to create an archive.", + ); + return; + } + + const shouldCreateArchive = + isTarType && (paths.length > 1 || singleIsDir); + + if (shouldCreateArchive) { + const gzip = contentType === "tgz"; + fileBuffer = await createTarBuffer(paths, gzip, statsMap); + detectedContentType = contentType as ContentType; + fileSize = fileBuffer.length; + } else { + // Single file upload (existing behavior) + const filePath = paths[0]; + fileBuffer = await readFile(filePath); + fileSize = fileBuffer.length; + + detectedContentType = contentType as ContentType; + if (!detectedContentType) { + const ext = extname(filePath).toLowerCase(); + detectedContentType = CONTENT_TYPE_MAP[ext] || "unspecified"; + } } } diff --git a/src/utils/commands.ts b/src/utils/commands.ts index 8e7ee83b..659a25ba 100644 --- a/src/utils/commands.ts +++ b/src/utils/commands.ts @@ -652,9 +652,9 @@ export function createProgram(): Command { object .command("upload ") .description( - "Upload file(s) or directory as an object. Multiple paths with --content-type tar|tgz creates an archive.", + "Upload file(s) or directory as an object. Multiple paths with --content-type tar|tgz creates an archive. Use - to read from stdin.", ) - .option("--name ", "Object name (required)") + .option("--name ", "Object name (required; mandatory for stdin)") .option( "--content-type ", "Content type: unspecified|text|binary|gzip|tar|tgz", From 058e707ec68c929e01e1868b73d7244f9104ee5c Mon Sep 17 00:00:00 2001 From: Jason Chiu Date: Fri, 24 Apr 2026 10:06:58 -0700 Subject: [PATCH 4/8] feat: use smart default download path in TUI screens Update both object list and detail TUI screens to use getDefaultDownloadPath, which infers file extensions from the object's content_type when pre-filling the download path. Co-Authored-By: Claude Opus 4.6 --- src/commands/object/list.tsx | 19 +++++++++++++++---- src/screens/ObjectDetailScreen.tsx | 10 ++++++++-- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/commands/object/list.tsx b/src/commands/object/list.tsx index 988b558f..dc8f2945 100644 --- a/src/commands/object/list.tsx +++ b/src/commands/object/list.tsx @@ -24,6 +24,7 @@ import { useListSearch } from "../../hooks/useListSearch.js"; import { useNavigation } from "../../store/navigationStore.js"; import { formatFileSize } from "../../services/objectService.js"; import { ConfirmationPrompt } from "../../components/ConfirmationPrompt.js"; +import { getDefaultDownloadPath } from "../../utils/downloadPath.js"; interface ListOptions { name?: string; @@ -471,8 +472,13 @@ const ListObjectsUI = ({ } else if (operationKey === "download") { // Show download prompt setSelectedObject(selectedObjectItem); - const defaultName = selectedObjectItem.name || selectedObjectItem.id; - setDownloadPath(`./${defaultName}`); + setDownloadPath( + getDefaultDownloadPath( + selectedObjectItem.name, + selectedObjectItem.id, + selectedObjectItem.content_type, + ), + ); setShowDownloadPrompt(true); } else if (operationKey === "delete") { // Show delete confirmation @@ -497,8 +503,13 @@ const ListObjectsUI = ({ // Download hotkey - show prompt setShowPopup(false); setSelectedObject(selectedObjectItem); - const defaultName = selectedObjectItem.name || selectedObjectItem.id; - setDownloadPath(`./${defaultName}`); + setDownloadPath( + getDefaultDownloadPath( + selectedObjectItem.name, + selectedObjectItem.id, + selectedObjectItem.content_type, + ), + ); setShowDownloadPrompt(true); } else if (input === "d") { // Delete hotkey - show confirmation diff --git a/src/screens/ObjectDetailScreen.tsx b/src/screens/ObjectDetailScreen.tsx index 46cb6254..2b61f907 100644 --- a/src/screens/ObjectDetailScreen.tsx +++ b/src/screens/ObjectDetailScreen.tsx @@ -32,6 +32,7 @@ import { Breadcrumb } from "../components/Breadcrumb.js"; import { Header } from "../components/Header.js"; import { ConfirmationPrompt } from "../components/ConfirmationPrompt.js"; import { colors } from "../utils/theme.js"; +import { getDefaultDownloadPath } from "../utils/downloadPath.js"; interface ObjectDetailScreenProps { objectId?: string; @@ -249,8 +250,13 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { switch (operation) { case "download": // Show download prompt - const defaultName = resource.name || resource.id; - setDownloadPath(`./${defaultName}`); + setDownloadPath( + getDefaultDownloadPath( + resource.name, + resource.id, + resource.content_type, + ), + ); setShowDownloadPrompt(true); break; case "delete": From 633528d0a0d39b9c0cefce1ea14c9f77fa66077a Mon Sep 17 00:00:00 2001 From: Jason Chiu Date: Fri, 24 Apr 2026 10:34:45 -0700 Subject: [PATCH 5/8] docs: update command structure for download/upload changes Auto-generated by docs:commands pre-push hook. Co-Authored-By: Claude Opus 4.6 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 537988c0..0860e5f0 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ rli blueprint from-dockerfile # Create a blueprint from a Dockerfile ```bash rli object list # List objects rli object get # Get object details -rli object download # Download object to local file +rli object download [path] # Download object to local file (path d... rli object upload # Upload file(s) or directory as an obj... rli object delete # Delete an object (irreversible) ``` From fd8eef6779e16c8a4d4dc8bffe6c6f804dc2f294 Mon Sep 17 00:00:00 2001 From: Jason Chiu Date: Fri, 24 Apr 2026 11:47:24 -0700 Subject: [PATCH 6/8] fix: remove .bin extension inference for binary content type Binary is too broad a category to infer a meaningful extension. Files with binary content type now keep their name unchanged. Co-Authored-By: Claude Opus 4.6 --- src/utils/downloadPath.ts | 4 ++-- tests/__tests__/utils/downloadPath.test.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/utils/downloadPath.ts b/src/utils/downloadPath.ts index 75b15705..51dda258 100644 --- a/src/utils/downloadPath.ts +++ b/src/utils/downloadPath.ts @@ -46,7 +46,7 @@ function hasSuffix(name: string): boolean { * * Rules (suffix comparisons are case-insensitive): * - text + no suffix → .txt - * - binary + no suffix → .bin + * - binary → no change (binary is too broad to infer an extension) * - gzip + suffix is .tar → replace with .tgz * - gzip + suffix not in {.gz,.gzip,.taz,.tgz,.tar} → append .gz * - tar + suffix != .tar → append .tar @@ -66,7 +66,7 @@ export function inferDownloadExtension( return hasSuffix(name) ? name : `${name}.txt`; case "binary": - return hasSuffix(name) ? name : `${name}.bin`; + return name; case "gzip": if (suffix === ".tar") { diff --git a/tests/__tests__/utils/downloadPath.test.ts b/tests/__tests__/utils/downloadPath.test.ts index 0028fc3f..d0d4c7e1 100644 --- a/tests/__tests__/utils/downloadPath.test.ts +++ b/tests/__tests__/utils/downloadPath.test.ts @@ -14,8 +14,8 @@ describe("inferDownloadExtension", () => { expect(inferDownloadExtension("myfile", "text")).toBe("myfile.txt"); }); - it("appends .bin for binary content type", () => { - expect(inferDownloadExtension("myfile", "binary")).toBe("myfile.bin"); + it("does not change name for binary content type", () => { + expect(inferDownloadExtension("myfile", "binary")).toBe("myfile"); }); it("appends .gz for gzip content type", () => { @@ -195,7 +195,7 @@ describe("getDefaultDownloadPath", () => { it("falls back to id when name is whitespace", () => { expect(getDefaultDownloadPath(" ", "obj_123", "binary")).toBe( - "./obj_123.bin", + "./obj_123", ); }); From f34d6c8f1eb95b2d8b1e1a9fde6c7bcfd5af78c2 Mon Sep 17 00:00:00 2001 From: Jason Chiu Date: Wed, 29 Apr 2026 17:02:59 -0700 Subject: [PATCH 7/8] feat: print pre-signed upload URL when invoked with 0 paths When `rli object upload --name ` is called without any file paths, create the object via the API and print the pre-signed upload URL to stdout. This enables external upload workflows (e.g. curl) matching the frontend's "Copy URL and Close" pattern. The object stays in UPLOADING state until the user uploads and completes externally. - Change `` to `[paths...]` (Commander optional variadic) - Skip interactive screen buffer for 0-paths mode - Default content type to "unspecified" when omitted - Add null guard for upload_url from API response Co-Authored-By: Claude Opus 4.6 --- README.md | 2 +- src/commands/object/upload.ts | 28 +++++- src/utils/commands.ts | 15 ++-- .../__tests__/commands/object/upload.test.ts | 86 +++++++++++++++++++ 4 files changed, 123 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0860e5f0..e15876e6 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ rli blueprint from-dockerfile # Create a blueprint from a Dockerfile rli object list # List objects rli object get # Get object details rli object download [path] # Download object to local file (path d... -rli object upload # Upload file(s) or directory as an obj... +rli object upload [paths...] # Upload file(s) or directory as an obj... rli object delete # Delete an object (irreversible) ``` diff --git a/src/commands/object/upload.ts b/src/commands/object/upload.ts index 34280c9d..84a3ba3e 100644 --- a/src/commands/object/upload.ts +++ b/src/commands/object/upload.ts @@ -175,7 +175,33 @@ export async function uploadObject(options: UploadObjectOptions) { const { paths, name, contentType, output: outputFormat } = options; if (paths.length === 0) { - outputError("At least one path is required"); + if (!name) { + outputError("--name is required when no paths are provided"); + } + const resolvedContentType: ContentType = + (contentType as ContentType) || "unspecified"; + + const createResponse = await client.objects.create({ + name, + content_type: resolvedContentType, + }); + + if (!createResponse.upload_url) { + outputError("API did not return an upload URL"); + } + + const result = { + id: createResponse.id, + name, + contentType: resolvedContentType, + uploadUrl: createResponse.upload_url, + }; + + if (!outputFormat || outputFormat === "text") { + console.log(createResponse.upload_url); + } else { + output(result, { format: outputFormat, defaultFormat: "json" }); + } return; } diff --git a/src/utils/commands.ts b/src/utils/commands.ts index 659a25ba..e28df063 100644 --- a/src/utils/commands.ts +++ b/src/utils/commands.ts @@ -650,11 +650,11 @@ export function createProgram(): Command { }); object - .command("upload ") + .command("upload [paths...]") .description( - "Upload file(s) or directory as an object. Multiple paths with --content-type tar|tgz creates an archive. Use - to read from stdin.", + "Upload file(s) or directory as an object. With no paths, creates the object and prints the upload URL. Use - to read from stdin.", ) - .option("--name ", "Object name (required; mandatory for stdin)") + .option("--name ", "Object name (required)") .option( "--content-type ", "Content type: unspecified|text|binary|gzip|tar|tgz", @@ -666,12 +666,15 @@ export function createProgram(): Command { ) .action(async (paths, options) => { const { uploadObject } = await import("../commands/object/upload.js"); - if (!options.output) { + const resolvedPaths = paths || []; + if (!options.output && resolvedPaths.length > 0) { const { runInteractiveCommand } = await import("../utils/interactiveCommand.js"); - await runInteractiveCommand(() => uploadObject({ paths, ...options })); + await runInteractiveCommand(() => + uploadObject({ paths: resolvedPaths, ...options }), + ); } else { - await uploadObject({ paths, ...options }); + await uploadObject({ paths: resolvedPaths, ...options }); } }); diff --git a/tests/__tests__/commands/object/upload.test.ts b/tests/__tests__/commands/object/upload.test.ts index 0e00c9e9..8a766f21 100644 --- a/tests/__tests__/commands/object/upload.test.ts +++ b/tests/__tests__/commands/object/upload.test.ts @@ -327,4 +327,90 @@ describe("uploadObject", () => { const body = fetchCall[1]?.body as Buffer; expect(body.toString()).toBe("fake tar content"); }); + + describe("0-paths mode (URL-only)", () => { + it("creates object and prints upload URL when no paths provided", async () => { + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + + const { uploadObject } = await import("@/commands/object/upload.js"); + await uploadObject({ + paths: [], + name: "url-only-object", + contentType: "text", + }); + + expect(mockCreate).toHaveBeenCalledWith({ + name: "url-only-object", + content_type: "text", + }); + expect(logSpy).toHaveBeenCalledWith("https://example.com/upload"); + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockComplete).not.toHaveBeenCalled(); + logSpy.mockRestore(); + }); + + it("defaults content type to unspecified when omitted", async () => { + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + + const { uploadObject } = await import("@/commands/object/upload.js"); + await uploadObject({ + paths: [], + name: "no-ct-object", + }); + + expect(mockCreate).toHaveBeenCalledWith({ + name: "no-ct-object", + content_type: "unspecified", + }); + expect(logSpy).toHaveBeenCalledWith("https://example.com/upload"); + logSpy.mockRestore(); + }); + + it("outputs structured result in JSON mode", async () => { + const { uploadObject } = await import("@/commands/object/upload.js"); + await uploadObject({ + paths: [], + name: "json-url-object", + contentType: "binary", + output: "json", + }); + + expect(mockOutput).toHaveBeenCalledWith( + { + id: "obj_test123", + name: "json-url-object", + contentType: "binary", + uploadUrl: "https://example.com/upload", + }, + { format: "json", defaultFormat: "json" }, + ); + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockComplete).not.toHaveBeenCalled(); + }); + + it("errors when --name is missing", async () => { + mockOutputError.mockImplementationOnce(() => { + throw new Error("exit"); + }); + mockOutputError.mockImplementationOnce(() => { + throw new Error("exit"); + }); + + const { uploadObject } = await import("@/commands/object/upload.js"); + try { + await uploadObject({ + paths: [], + name: "", + }); + } catch { + // expected: mockOutputError throws to simulate process.exit + } + + expect(mockOutputError).toHaveBeenNthCalledWith( + 1, + "--name is required when no paths are provided", + ); + expect(mockCreate).not.toHaveBeenCalled(); + }); + }); }); From 22888ba1e331224302621878e1f2f50885ec4adc Mon Sep 17 00:00:00 2001 From: Jason Chiu Date: Thu, 30 Apr 2026 11:40:35 -0700 Subject: [PATCH 8/8] feat: auto-detect piped stdin for upload and extend processUtils I/O When no paths are provided and stdin is a pipe (not a terminal), upload now reads from piped stdin instead of printing the pre-signed URL. This enables `echo data | rli obj upload --name foo --content-type text` without explicitly passing `-`. Zero-byte pipes are handled correctly. Extend processUtils with Buffer support on stdout/stderr.write and AsyncIterable on stdin, so upload and download go through the mockable abstraction instead of process globals directly. Co-Authored-By: Claude Opus 4.6 --- README.md | 4 +- src/commands/object/download.ts | 3 +- src/commands/object/upload.ts | 57 +++-- src/utils/commands.ts | 4 +- src/utils/processUtils.ts | 18 +- .../commands/object/download.test.ts | 208 +++++++++++++++ .../__tests__/commands/object/upload.test.ts | 237 ++++++++++++++++++ 7 files changed, 494 insertions(+), 37 deletions(-) create mode 100644 tests/__tests__/commands/object/download.test.ts diff --git a/README.md b/README.md index e15876e6..c470298e 100644 --- a/README.md +++ b/README.md @@ -147,8 +147,8 @@ rli blueprint from-dockerfile # Create a blueprint from a Dockerfile ```bash rli object list # List objects rli object get # Get object details -rli object download [path] # Download object to local file (path d... -rli object upload [paths...] # Upload file(s) or directory as an obj... +rli object download [path] # Download an object. Omit path to save... +rli object upload [paths...] # Upload an object. Reads from piped st... rli object delete # Delete an object (irreversible) ``` diff --git a/src/commands/object/download.ts b/src/commands/object/download.ts index 2a311322..02e3b88a 100644 --- a/src/commands/object/download.ts +++ b/src/commands/object/download.ts @@ -72,8 +72,7 @@ export async function downloadObject(options: DownloadObjectOptions) { "Warning: writing binary data to terminal; pipe to a file or command instead\n", ); } - // Raw process.stdout for binary data (processUtils.stdout.write only accepts string) - process.stdout.write(buffer); + processUtils.stdout.write(buffer); } else { await writeFile(resolvedPath, buffer); } diff --git a/src/commands/object/upload.ts b/src/commands/object/upload.ts index 84a3ba3e..a40b4434 100644 --- a/src/commands/object/upload.ts +++ b/src/commands/object/upload.ts @@ -8,6 +8,7 @@ import { createTar, createTarGzip } from "nanotar"; import type { TarFileInput } from "nanotar"; import { getClient } from "../../utils/client.js"; import { output, outputError } from "../../utils/output.js"; +import { processUtils } from "../../utils/processUtils.js"; interface UploadObjectOptions { paths: string[]; @@ -163,7 +164,7 @@ export async function createTarBuffer( async function readStdinBuffer(): Promise { const chunks: Buffer[] = []; - for await (const chunk of process.stdin) { + for await (const chunk of processUtils.stdin) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } return Buffer.concat(chunks); @@ -175,34 +176,40 @@ export async function uploadObject(options: UploadObjectOptions) { const { paths, name, contentType, output: outputFormat } = options; if (paths.length === 0) { - if (!name) { - outputError("--name is required when no paths are provided"); - } - const resolvedContentType: ContentType = - (contentType as ContentType) || "unspecified"; + if (!processUtils.stdin.isTTY) { + // Piped stdin detected — normalize to explicit stdin path below + paths.push("-"); + } else { + // Interactive terminal: print pre-signed upload URL + if (!name) { + outputError("--name is required when no paths are provided"); + } + const resolvedContentType: ContentType = + (contentType as ContentType) || "unspecified"; - const createResponse = await client.objects.create({ - name, - content_type: resolvedContentType, - }); + const createResponse = await client.objects.create({ + name, + content_type: resolvedContentType, + }); - if (!createResponse.upload_url) { - outputError("API did not return an upload URL"); - } - - const result = { - id: createResponse.id, - name, - contentType: resolvedContentType, - uploadUrl: createResponse.upload_url, - }; + if (!createResponse.upload_url) { + outputError("API did not return an upload URL"); + } - if (!outputFormat || outputFormat === "text") { - console.log(createResponse.upload_url); - } else { - output(result, { format: outputFormat, defaultFormat: "json" }); + const result = { + id: createResponse.id, + name, + contentType: resolvedContentType, + uploadUrl: createResponse.upload_url, + }; + + if (!outputFormat || outputFormat === "text") { + console.log(createResponse.upload_url); + } else { + output(result, { format: outputFormat, defaultFormat: "json" }); + } + return; } - return; } const hasStdin = paths.includes("-"); diff --git a/src/utils/commands.ts b/src/utils/commands.ts index e28df063..0631329a 100644 --- a/src/utils/commands.ts +++ b/src/utils/commands.ts @@ -632,7 +632,7 @@ export function createProgram(): Command { object .command("download [path]") .description( - "Download object to local file (path defaults to ./ with inferred extension; use - for stdout)", + "Download an object. Omit path to save as ./ with inferred extension. Use - to write to stdout.", ) .option("--extract", "Extract downloaded archive after download") .option( @@ -652,7 +652,7 @@ export function createProgram(): Command { object .command("upload [paths...]") .description( - "Upload file(s) or directory as an object. With no paths, creates the object and prints the upload URL. Use - to read from stdin.", + "Upload an object. Reads from piped stdin when no paths are given; prints a pre-signed upload URL if stdin is a terminal. Use - to explicitly read stdin. Multiple paths with --content-type tar|tgz creates an archive.", ) .option("--name ", "Object name (required)") .option( diff --git a/src/utils/processUtils.ts b/src/utils/processUtils.ts index a9001d9f..f526310f 100644 --- a/src/utils/processUtils.ts +++ b/src/utils/processUtils.ts @@ -29,7 +29,7 @@ export interface ProcessUtils { * Standard output operations */ stdout: { - write: (data: string) => boolean; + write: (data: string | Buffer) => boolean; isTTY: boolean; }; @@ -37,7 +37,7 @@ export interface ProcessUtils { * Standard error operations */ stderr: { - write: (data: string) => boolean; + write: (data: string | Buffer) => boolean; isTTY: boolean; }; @@ -52,6 +52,7 @@ export interface ProcessUtils { event: string, listener: (...args: unknown[]) => void, ) => void; + [Symbol.asyncIterator]: () => AsyncIterator; }; /** @@ -91,14 +92,14 @@ export const processUtils: ProcessUtils = { exit: originalExit, stdout: { - write: (data: string) => originalStdoutWrite(data), + write: (data: string | Buffer) => originalStdoutWrite(data), get isTTY() { return process.stdout.isTTY ?? false; }, }, stderr: { - write: (data: string) => originalStderrWrite(data), + write: (data: string | Buffer) => originalStderrWrite(data), get isTTY() { return process.stderr.isTTY ?? false; }, @@ -111,6 +112,8 @@ export const processUtils: ProcessUtils = { setRawMode: process.stdin.setRawMode?.bind(process.stdin), on: process.stdin.on.bind(process.stdin), removeListener: process.stdin.removeListener.bind(process.stdin), + [Symbol.asyncIterator]: () => + process.stdin[Symbol.asyncIterator]() as AsyncIterator, }, cwd: originalCwd, @@ -130,8 +133,10 @@ export const processUtils: ProcessUtils = { */ export function resetProcessUtils(): void { processUtils.exit = originalExit; - processUtils.stdout.write = (data: string) => originalStdoutWrite(data); - processUtils.stderr.write = (data: string) => originalStderrWrite(data); + processUtils.stdout.write = (data: string | Buffer) => + originalStdoutWrite(data); + processUtils.stderr.write = (data: string | Buffer) => + originalStderrWrite(data); processUtils.cwd = originalCwd; processUtils.on = originalOn; processUtils.off = originalOff; @@ -161,6 +166,7 @@ export function createMockProcessUtils(): ProcessUtils { setRawMode: () => {}, on: () => {}, removeListener: () => {}, + async *[Symbol.asyncIterator]() {}, }, cwd: () => "/mock/cwd", on: () => {}, diff --git a/tests/__tests__/commands/object/download.test.ts b/tests/__tests__/commands/object/download.test.ts new file mode 100644 index 00000000..2169611d --- /dev/null +++ b/tests/__tests__/commands/object/download.test.ts @@ -0,0 +1,208 @@ +import { jest, describe, it, expect, beforeEach } from "@jest/globals"; + +const mockRetrieve = jest.fn(); +const mockDownload = jest.fn(); +jest.unstable_mockModule("@/utils/client.js", () => ({ + getClient: () => ({ + objects: { + retrieve: mockRetrieve, + download: mockDownload, + }, + }), +})); + +const mockOutput = jest.fn(); +const mockOutputError = jest.fn(); +jest.unstable_mockModule("@/utils/output.js", () => ({ + output: mockOutput, + outputError: mockOutputError, +})); + +const mockWriteFile = jest.fn(); +jest.unstable_mockModule("fs/promises", () => ({ + writeFile: mockWriteFile, +})); + +const mockStdoutWrite = jest.fn(() => true); +const mockStderrWrite = jest.fn(() => true); +const mockProcessUtils = { + stdout: { + write: mockStdoutWrite, + isTTY: false, + }, + stderr: { + write: mockStderrWrite, + isTTY: false, + }, +}; +jest.unstable_mockModule("@/utils/processUtils.js", () => ({ + processUtils: mockProcessUtils, +})); + +const mockFetch = jest.fn(); +globalThis.fetch = mockFetch; + +const TEST_BUFFER = Buffer.from("file content bytes"); + +describe("downloadObject", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockProcessUtils.stdout.isTTY = false; + + mockDownload.mockResolvedValue({ + download_url: "https://example.com/download", + }); + mockFetch.mockResolvedValue({ + ok: true, + arrayBuffer: async () => TEST_BUFFER.buffer.slice( + TEST_BUFFER.byteOffset, + TEST_BUFFER.byteOffset + TEST_BUFFER.byteLength, + ), + } as Response); + mockWriteFile.mockResolvedValue(undefined); + }); + + it("downloads to specified file path", async () => { + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + + const { downloadObject } = await import("@/commands/object/download.js"); + await downloadObject({ + id: "obj_123", + path: "/tmp/output.bin", + }); + + expect(mockWriteFile).toHaveBeenCalledWith( + "/tmp/output.bin", + expect.any(Buffer), + ); + const writtenBuffer = mockWriteFile.mock.calls[0][1] as Buffer; + expect(writtenBuffer.toString()).toBe("file content bytes"); + expect(logSpy).toHaveBeenCalledWith("/tmp/output.bin"); + expect(mockStdoutWrite).not.toHaveBeenCalled(); + logSpy.mockRestore(); + }); + + it("auto-resolves path from object name when no path provided", async () => { + mockRetrieve.mockResolvedValue({ + id: "obj_123", + name: "myfile", + content_type: "text", + }); + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + + const { downloadObject } = await import("@/commands/object/download.js"); + await downloadObject({ + id: "obj_123", + }); + + expect(mockRetrieve).toHaveBeenCalledWith("obj_123"); + expect(mockWriteFile).toHaveBeenCalledWith( + "./myfile.txt", + expect.any(Buffer), + ); + expect(logSpy).toHaveBeenCalledWith("./myfile.txt"); + logSpy.mockRestore(); + }); + + it("writes to stdout when path is -", async () => { + mockRetrieve.mockResolvedValue({ + id: "obj_123", + name: "myfile", + content_type: "text", + }); + + const { downloadObject } = await import("@/commands/object/download.js"); + await downloadObject({ + id: "obj_123", + path: "-", + }); + + expect(mockStdoutWrite).toHaveBeenCalledWith(expect.any(Buffer)); + const writtenBuffer = mockStdoutWrite.mock.calls[0][0] as Buffer; + expect(writtenBuffer.toString()).toBe("file content bytes"); + expect(mockWriteFile).not.toHaveBeenCalled(); + }); + + it("warns about binary content when writing to stdout on TTY", async () => { + mockProcessUtils.stdout.isTTY = true; + mockRetrieve.mockResolvedValue({ + id: "obj_123", + name: "myfile", + content_type: "binary", + }); + + const { downloadObject } = await import("@/commands/object/download.js"); + await downloadObject({ + id: "obj_123", + path: "-", + }); + + expect(mockStderrWrite).toHaveBeenCalledWith( + expect.stringContaining("binary data"), + ); + }); + + it("does not warn about text content when writing to stdout on TTY", async () => { + mockProcessUtils.stdout.isTTY = true; + mockRetrieve.mockResolvedValue({ + id: "obj_123", + name: "myfile", + content_type: "text", + }); + + const { downloadObject } = await import("@/commands/object/download.js"); + await downloadObject({ + id: "obj_123", + path: "-", + }); + + expect(mockStderrWrite).not.toHaveBeenCalledWith( + expect.stringContaining("binary data"), + ); + }); + + it("outputs structured JSON to stderr in stdout mode", async () => { + mockRetrieve.mockResolvedValue({ + id: "obj_123", + name: "myfile", + content_type: "text", + }); + + const { downloadObject } = await import("@/commands/object/download.js"); + await downloadObject({ + id: "obj_123", + path: "-", + output: "json", + }); + + const stderrCall = mockStderrWrite.mock.calls.find( + (call) => typeof call[0] === "string" && call[0].includes('"id"'), + ); + expect(stderrCall).toBeDefined(); + const parsed = JSON.parse(stderrCall![0] as string); + expect(parsed).toEqual({ + id: "obj_123", + path: "-", + extracted: false, + }); + }); + + it("does not output structured result for text mode in stdout mode", async () => { + mockRetrieve.mockResolvedValue({ + id: "obj_123", + name: "myfile", + content_type: "text", + }); + + const { downloadObject } = await import("@/commands/object/download.js"); + await downloadObject({ + id: "obj_123", + path: "-", + }); + + const jsonCall = mockStderrWrite.mock.calls.find( + (call) => typeof call[0] === "string" && call[0].includes('"id"'), + ); + expect(jsonCall).toBeUndefined(); + }); +}); diff --git a/tests/__tests__/commands/object/upload.test.ts b/tests/__tests__/commands/object/upload.test.ts index 8a766f21..3ee57129 100644 --- a/tests/__tests__/commands/object/upload.test.ts +++ b/tests/__tests__/commands/object/upload.test.ts @@ -27,6 +27,25 @@ jest.unstable_mockModule("@/utils/output.js", () => ({ outputError: mockOutputError, })); +// Mock processUtils for stdin control +const mockProcessUtils = { + stdin: { + isTTY: true, + async *[Symbol.asyncIterator](): AsyncGenerator {}, + }, +}; +jest.unstable_mockModule("@/utils/processUtils.js", () => ({ + processUtils: mockProcessUtils, +})); + +function setMockStdin(chunks: Buffer[]) { + mockProcessUtils.stdin[Symbol.asyncIterator] = async function* () { + for (const chunk of chunks) { + yield chunk; + } + }; +} + // Mock fetch for upload const mockFetch = jest.fn(); globalThis.fetch = mockFetch; @@ -183,6 +202,8 @@ describe("uploadObject", () => { }); mockFetch.mockResolvedValue({ ok: true } as Response); mockComplete.mockResolvedValue({}); + mockProcessUtils.stdin.isTTY = true; + setMockStdin([]); }); afterEach(async () => { @@ -413,4 +434,220 @@ describe("uploadObject", () => { expect(mockCreate).not.toHaveBeenCalled(); }); }); + + describe("stdin upload (explicit - path)", () => { + it("reads from stdin and uploads the data", async () => { + setMockStdin([Buffer.from("stdin content")]); + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + + const { uploadObject } = await import("@/commands/object/upload.js"); + await uploadObject({ + paths: ["-"], + name: "stdin-object", + contentType: "text", + }); + + logSpy.mockRestore(); + + expect(mockCreate).toHaveBeenCalledWith({ + name: "stdin-object", + content_type: "text", + }); + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1]?.body as Buffer; + expect(body.toString()).toBe("stdin content"); + expect(mockComplete).toHaveBeenCalledWith("obj_test123"); + }); + + it("uploads 0-byte buffer from empty stdin", async () => { + setMockStdin([]); + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + + const { uploadObject } = await import("@/commands/object/upload.js"); + await uploadObject({ + paths: ["-"], + name: "empty-stdin", + contentType: "binary", + }); + + logSpy.mockRestore(); + + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1]?.body as Buffer; + expect(body.length).toBe(0); + expect(mockComplete).toHaveBeenCalledWith("obj_test123"); + }); + + it("errors when --name is missing", async () => { + mockOutputError.mockImplementationOnce(() => { + throw new Error("exit"); + }); + + const { uploadObject } = await import("@/commands/object/upload.js"); + try { + await uploadObject({ + paths: ["-"], + name: "", + contentType: "text", + }); + } catch { + // expected + } + + expect(mockOutputError).toHaveBeenCalledWith( + "--name is required when uploading from stdin", + ); + expect(mockCreate).not.toHaveBeenCalled(); + }); + + it("errors when --content-type is missing", async () => { + mockOutputError.mockImplementationOnce(() => { + throw new Error("exit"); + }); + + const { uploadObject } = await import("@/commands/object/upload.js"); + try { + await uploadObject({ + paths: ["-"], + name: "no-ct-stdin", + }); + } catch { + // expected + } + + expect(mockOutputError).toHaveBeenCalledWith( + "--content-type is required when uploading from stdin", + ); + }); + + it("errors when stdin is mixed with other paths", async () => { + const filePath = join(testDir, "file.txt"); + await writeFile(filePath, "data"); + + const { uploadObject } = await import("@/commands/object/upload.js"); + await uploadObject({ + paths: ["-", filePath], + name: "mixed", + contentType: "text", + }); + + expect(mockOutputError).toHaveBeenCalledWith( + "Cannot mix stdin (-) with other paths. Use - alone or provide only file/directory paths.", + ); + expect(mockCreate).not.toHaveBeenCalled(); + }); + }); + + describe("0-paths with piped stdin", () => { + it("reads piped stdin and uploads instead of printing URL", async () => { + mockProcessUtils.stdin.isTTY = false; + setMockStdin([Buffer.from("piped data")]); + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + + const { uploadObject } = await import("@/commands/object/upload.js"); + await uploadObject({ + paths: [], + name: "piped-object", + contentType: "text", + }); + + expect(mockCreate).toHaveBeenCalledWith({ + name: "piped-object", + content_type: "text", + }); + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1]?.body as Buffer; + expect(body.toString()).toBe("piped data"); + expect(mockComplete).toHaveBeenCalledWith("obj_test123"); + expect(logSpy).toHaveBeenCalledWith("obj_test123"); + logSpy.mockRestore(); + }); + + it("uploads 0-byte buffer from empty piped stdin", async () => { + mockProcessUtils.stdin.isTTY = false; + setMockStdin([]); + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + + const { uploadObject } = await import("@/commands/object/upload.js"); + await uploadObject({ + paths: [], + name: "empty-piped", + contentType: "binary", + }); + + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1]?.body as Buffer; + expect(body.length).toBe(0); + expect(mockComplete).toHaveBeenCalledWith("obj_test123"); + logSpy.mockRestore(); + }); + + it("errors when --name is missing with piped stdin", async () => { + mockProcessUtils.stdin.isTTY = false; + mockOutputError.mockImplementationOnce(() => { + throw new Error("exit"); + }); + + const { uploadObject } = await import("@/commands/object/upload.js"); + try { + await uploadObject({ + paths: [], + name: "", + contentType: "text", + }); + } catch { + // expected + } + + expect(mockOutputError).toHaveBeenCalledWith( + "--name is required when uploading from stdin", + ); + expect(mockCreate).not.toHaveBeenCalled(); + }); + + it("errors when --content-type is missing with piped stdin", async () => { + mockProcessUtils.stdin.isTTY = false; + mockOutputError.mockImplementationOnce(() => { + throw new Error("exit"); + }); + + const { uploadObject } = await import("@/commands/object/upload.js"); + try { + await uploadObject({ + paths: [], + name: "no-ct-piped", + }); + } catch { + // expected + } + + expect(mockOutputError).toHaveBeenCalledWith( + "--content-type is required when uploading from stdin", + ); + }); + + it("outputs structured JSON result for piped stdin upload", async () => { + mockProcessUtils.stdin.isTTY = false; + setMockStdin([Buffer.from("json-piped")]); + + const { uploadObject } = await import("@/commands/object/upload.js"); + await uploadObject({ + paths: [], + name: "json-piped-object", + contentType: "text", + output: "json", + }); + + expect(mockOutput).toHaveBeenCalledWith( + { + id: "obj_test123", + name: "json-piped-object", + contentType: "text", + size: 10, + }, + { format: "json", defaultFormat: "json" }, + ); + expect(mockComplete).toHaveBeenCalledWith("obj_test123"); + }); + }); });