diff --git a/README.md b/README.md index 537988c0..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 # Download object to local file -rli object upload # 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 b2289fc5..02e3b88a 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,39 @@ 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", + ); + } + processUtils.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/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/commands/object/upload.ts b/src/commands/object/upload.ts index b5f4fde1..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[]; @@ -161,79 +162,145 @@ export async function createTarBuffer( return Buffer.from(data); } +async function readStdinBuffer(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of processUtils.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks); +} + export async function uploadObject(options: UploadObjectOptions) { try { const client = getClient(); const { paths, name, contentType, output: outputFormat } = options; if (paths.length === 0) { - outputError("At least one path is required"); - return; - } + 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"; - // 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; + 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" }); } - statsMap.set(resolve(p), s); - } catch { - outputError(`Path does not exist: ${p}`); return; } } - 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(); + const hasStdin = paths.includes("-"); + const isStdin = paths.length === 1 && hasStdin; - // 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/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": diff --git a/src/utils/commands.ts b/src/utils/commands.ts index ff9bd0e6..0631329a 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 an object. Omit path to save as ./ with inferred extension. Use - to write to stdout.", + ) .option("--extract", "Extract downloaded archive after download") .option( "--duration-seconds ", @@ -648,9 +650,9 @@ 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.", + "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( @@ -664,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/src/utils/downloadPath.ts b/src/utils/downloadPath.ts new file mode 100644 index 00000000..51dda258 --- /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 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 + * - 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 name; + + 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/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 0e00c9e9..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 () => { @@ -327,4 +348,306 @@ 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(); + }); + }); + + 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"); + }); + }); }); diff --git a/tests/__tests__/utils/downloadPath.test.ts b/tests/__tests__/utils/downloadPath.test.ts new file mode 100644 index 00000000..d0d4c7e1 --- /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("does not change name for binary content type", () => { + expect(inferDownloadExtension("myfile", "binary")).toBe("myfile"); + }); + + 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", + ); + }); + + 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", + ); + }); +});