Skip to content
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,8 @@ rli blueprint from-dockerfile # Create a blueprint from a Dockerfile
```bash
rli object list # List objects
rli object get <id> # Get object details
rli object download <id> <path> # Download object to local file
rli object upload <paths...> # Upload file(s) or directory as an obj...
rli object download <id> [path] # Download an object. Omit path to save...
rli object upload [paths...] # Upload an object. Reads from piped st...
rli object delete <id> # Delete an object (irreversible)
```

Expand Down
60 changes: 54 additions & 6 deletions src/commands/object/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand All @@ -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" });
}
Expand Down
19 changes: 15 additions & 4 deletions src/commands/object/list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
161 changes: 114 additions & 47 deletions src/commands/object/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -161,79 +162,145 @@ export async function createTarBuffer(
return Buffer.from(data);
}

async function readStdinBuffer(): Promise<Buffer> {
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<string, Awaited<ReturnType<typeof lstat>>>();
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<string, Awaited<ReturnType<typeof lstat>>>();
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";
}
}
}

Expand Down
10 changes: 8 additions & 2 deletions src/screens/ObjectDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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":
Expand Down
Loading
Loading