Skip to content

Commit 22888ba

Browse files
jason-rlclaude
andcommitted
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 <noreply@anthropic.com>
1 parent f34d6c8 commit 22888ba

7 files changed

Lines changed: 494 additions & 37 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,8 @@ rli blueprint from-dockerfile # Create a blueprint from a Dockerfile
147147
```bash
148148
rli object list # List objects
149149
rli object get <id> # Get object details
150-
rli object download <id> [path] # Download object to local file (path d...
151-
rli object upload [paths...] # Upload file(s) or directory as an obj...
150+
rli object download <id> [path] # Download an object. Omit path to save...
151+
rli object upload [paths...] # Upload an object. Reads from piped st...
152152
rli object delete <id> # Delete an object (irreversible)
153153
```
154154

src/commands/object/download.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,7 @@ export async function downloadObject(options: DownloadObjectOptions) {
7272
"Warning: writing binary data to terminal; pipe to a file or command instead\n",
7373
);
7474
}
75-
// Raw process.stdout for binary data (processUtils.stdout.write only accepts string)
76-
process.stdout.write(buffer);
75+
processUtils.stdout.write(buffer);
7776
} else {
7877
await writeFile(resolvedPath, buffer);
7978
}

src/commands/object/upload.ts

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { createTar, createTarGzip } from "nanotar";
88
import type { TarFileInput } from "nanotar";
99
import { getClient } from "../../utils/client.js";
1010
import { output, outputError } from "../../utils/output.js";
11+
import { processUtils } from "../../utils/processUtils.js";
1112

1213
interface UploadObjectOptions {
1314
paths: string[];
@@ -163,7 +164,7 @@ export async function createTarBuffer(
163164

164165
async function readStdinBuffer(): Promise<Buffer> {
165166
const chunks: Buffer[] = [];
166-
for await (const chunk of process.stdin) {
167+
for await (const chunk of processUtils.stdin) {
167168
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
168169
}
169170
return Buffer.concat(chunks);
@@ -175,34 +176,40 @@ export async function uploadObject(options: UploadObjectOptions) {
175176
const { paths, name, contentType, output: outputFormat } = options;
176177

177178
if (paths.length === 0) {
178-
if (!name) {
179-
outputError("--name is required when no paths are provided");
180-
}
181-
const resolvedContentType: ContentType =
182-
(contentType as ContentType) || "unspecified";
179+
if (!processUtils.stdin.isTTY) {
180+
// Piped stdin detected — normalize to explicit stdin path below
181+
paths.push("-");
182+
} else {
183+
// Interactive terminal: print pre-signed upload URL
184+
if (!name) {
185+
outputError("--name is required when no paths are provided");
186+
}
187+
const resolvedContentType: ContentType =
188+
(contentType as ContentType) || "unspecified";
183189

184-
const createResponse = await client.objects.create({
185-
name,
186-
content_type: resolvedContentType,
187-
});
190+
const createResponse = await client.objects.create({
191+
name,
192+
content_type: resolvedContentType,
193+
});
188194

189-
if (!createResponse.upload_url) {
190-
outputError("API did not return an upload URL");
191-
}
192-
193-
const result = {
194-
id: createResponse.id,
195-
name,
196-
contentType: resolvedContentType,
197-
uploadUrl: createResponse.upload_url,
198-
};
195+
if (!createResponse.upload_url) {
196+
outputError("API did not return an upload URL");
197+
}
199198

200-
if (!outputFormat || outputFormat === "text") {
201-
console.log(createResponse.upload_url);
202-
} else {
203-
output(result, { format: outputFormat, defaultFormat: "json" });
199+
const result = {
200+
id: createResponse.id,
201+
name,
202+
contentType: resolvedContentType,
203+
uploadUrl: createResponse.upload_url,
204+
};
205+
206+
if (!outputFormat || outputFormat === "text") {
207+
console.log(createResponse.upload_url);
208+
} else {
209+
output(result, { format: outputFormat, defaultFormat: "json" });
210+
}
211+
return;
204212
}
205-
return;
206213
}
207214

208215
const hasStdin = paths.includes("-");

src/utils/commands.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -632,7 +632,7 @@ export function createProgram(): Command {
632632
object
633633
.command("download <id> [path]")
634634
.description(
635-
"Download object to local file (path defaults to ./<name> with inferred extension; use - for stdout)",
635+
"Download an object. Omit path to save as ./<name> with inferred extension. Use - to write to stdout.",
636636
)
637637
.option("--extract", "Extract downloaded archive after download")
638638
.option(
@@ -652,7 +652,7 @@ export function createProgram(): Command {
652652
object
653653
.command("upload [paths...]")
654654
.description(
655-
"Upload file(s) or directory as an object. With no paths, creates the object and prints the upload URL. Use - to read from stdin.",
655+
"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.",
656656
)
657657
.option("--name <name>", "Object name (required)")
658658
.option(

src/utils/processUtils.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,15 @@ export interface ProcessUtils {
2929
* Standard output operations
3030
*/
3131
stdout: {
32-
write: (data: string) => boolean;
32+
write: (data: string | Buffer) => boolean;
3333
isTTY: boolean;
3434
};
3535

3636
/**
3737
* Standard error operations
3838
*/
3939
stderr: {
40-
write: (data: string) => boolean;
40+
write: (data: string | Buffer) => boolean;
4141
isTTY: boolean;
4242
};
4343

@@ -52,6 +52,7 @@ export interface ProcessUtils {
5252
event: string,
5353
listener: (...args: unknown[]) => void,
5454
) => void;
55+
[Symbol.asyncIterator]: () => AsyncIterator<Buffer>;
5556
};
5657

5758
/**
@@ -91,14 +92,14 @@ export const processUtils: ProcessUtils = {
9192
exit: originalExit,
9293

9394
stdout: {
94-
write: (data: string) => originalStdoutWrite(data),
95+
write: (data: string | Buffer) => originalStdoutWrite(data),
9596
get isTTY() {
9697
return process.stdout.isTTY ?? false;
9798
},
9899
},
99100

100101
stderr: {
101-
write: (data: string) => originalStderrWrite(data),
102+
write: (data: string | Buffer) => originalStderrWrite(data),
102103
get isTTY() {
103104
return process.stderr.isTTY ?? false;
104105
},
@@ -111,6 +112,8 @@ export const processUtils: ProcessUtils = {
111112
setRawMode: process.stdin.setRawMode?.bind(process.stdin),
112113
on: process.stdin.on.bind(process.stdin),
113114
removeListener: process.stdin.removeListener.bind(process.stdin),
115+
[Symbol.asyncIterator]: () =>
116+
process.stdin[Symbol.asyncIterator]() as AsyncIterator<Buffer>,
114117
},
115118

116119
cwd: originalCwd,
@@ -130,8 +133,10 @@ export const processUtils: ProcessUtils = {
130133
*/
131134
export function resetProcessUtils(): void {
132135
processUtils.exit = originalExit;
133-
processUtils.stdout.write = (data: string) => originalStdoutWrite(data);
134-
processUtils.stderr.write = (data: string) => originalStderrWrite(data);
136+
processUtils.stdout.write = (data: string | Buffer) =>
137+
originalStdoutWrite(data);
138+
processUtils.stderr.write = (data: string | Buffer) =>
139+
originalStderrWrite(data);
135140
processUtils.cwd = originalCwd;
136141
processUtils.on = originalOn;
137142
processUtils.off = originalOff;
@@ -161,6 +166,7 @@ export function createMockProcessUtils(): ProcessUtils {
161166
setRawMode: () => {},
162167
on: () => {},
163168
removeListener: () => {},
169+
async *[Symbol.asyncIterator]() {},
164170
},
165171
cwd: () => "/mock/cwd",
166172
on: () => {},

0 commit comments

Comments
 (0)