Skip to content

Commit 8e6f7f5

Browse files
jason-rlclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 7bb6103 commit 8e6f7f5

2 files changed

Lines changed: 85 additions & 51 deletions

File tree

src/commands/object/upload.ts

Lines changed: 83 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,14 @@ export async function createTarBuffer(
161161
return Buffer.from(data);
162162
}
163163

164+
async function readStdinBuffer(): Promise<Buffer> {
165+
const chunks: Buffer[] = [];
166+
for await (const chunk of process.stdin) {
167+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
168+
}
169+
return Buffer.concat(chunks);
170+
}
171+
164172
export async function uploadObject(options: UploadObjectOptions) {
165173
try {
166174
const client = getClient();
@@ -171,69 +179,95 @@ export async function uploadObject(options: UploadObjectOptions) {
171179
return;
172180
}
173181

174-
// Validate all paths exist (use lstat to match collectEntries and detect symlinks)
175-
// Key by resolved absolute path so collectEntries can reuse stats
176-
const statsMap = new Map<string, Awaited<ReturnType<typeof lstat>>>();
177-
for (const p of paths) {
178-
try {
179-
const s = await lstat(p);
180-
if (s.isSymbolicLink()) {
181-
outputError(
182-
`Path is a symlink: ${p}. Resolve the symlink or pass the target path directly.`,
183-
);
184-
return;
185-
}
186-
statsMap.set(resolve(p), s);
187-
} catch {
188-
outputError(`Path does not exist: ${p}`);
189-
return;
190-
}
191-
}
182+
const hasStdin = paths.includes("-");
183+
const isStdin = paths.length === 1 && hasStdin;
192184

193-
const isTarType = contentType === "tar" || contentType === "tgz";
194-
const isSinglePath = paths.length === 1;
195-
const firstStats = isSinglePath
196-
? statsMap.get(resolve(paths[0]))!
197-
: undefined;
198-
const singleIsDir = isSinglePath && firstStats!.isDirectory();
199-
200-
// Multi-path requires tar/tgz content type
201-
if (paths.length > 1 && !isTarType) {
185+
// stdin cannot be mixed with other paths (e.g. `upload - file1.txt`)
186+
if (hasStdin && !isStdin) {
202187
outputError(
203-
"Multiple paths require --content-type tar or --content-type tgz",
188+
"Cannot mix stdin (-) with other paths. Use - alone or provide only file/directory paths.",
204189
);
205-
return;
206190
}
207191

208-
// Directory without tar/tgz type
209-
if (singleIsDir && !isTarType) {
210-
outputError(
211-
"Cannot upload a directory directly. Use --content-type tar or --content-type tgz to create an archive.",
212-
);
213-
return;
192+
if (isStdin) {
193+
if (!name) {
194+
outputError("--name is required when uploading from stdin");
195+
}
196+
if (!contentType) {
197+
outputError("--content-type is required when uploading from stdin");
198+
}
214199
}
215200

216201
let fileBuffer: Buffer;
217202
let detectedContentType: ContentType;
218203
let fileSize: number;
219204

220-
const shouldCreateArchive = isTarType && (paths.length > 1 || singleIsDir);
221-
222-
if (shouldCreateArchive) {
223-
const gzip = contentType === "tgz";
224-
fileBuffer = await createTarBuffer(paths, gzip, statsMap);
225-
detectedContentType = contentType as ContentType;
205+
if (isStdin) {
206+
fileBuffer = await readStdinBuffer();
226207
fileSize = fileBuffer.length;
208+
detectedContentType = contentType as ContentType;
227209
} else {
228-
// Single file upload (existing behavior)
229-
const filePath = paths[0];
230-
fileBuffer = await readFile(filePath);
231-
fileSize = fileBuffer.length;
210+
// Validate all paths exist (use lstat to match collectEntries and detect symlinks)
211+
// Key by resolved absolute path so collectEntries can reuse stats
212+
const statsMap = new Map<string, Awaited<ReturnType<typeof lstat>>>();
213+
for (const p of paths) {
214+
try {
215+
const s = await lstat(p);
216+
if (s.isSymbolicLink()) {
217+
outputError(
218+
`Path is a symlink: ${p}. Resolve the symlink or pass the target path directly.`,
219+
);
220+
return;
221+
}
222+
statsMap.set(resolve(p), s);
223+
} catch {
224+
outputError(`Path does not exist: ${p}`);
225+
return;
226+
}
227+
}
232228

233-
detectedContentType = contentType as ContentType;
234-
if (!detectedContentType) {
235-
const ext = extname(filePath).toLowerCase();
236-
detectedContentType = CONTENT_TYPE_MAP[ext] || "unspecified";
229+
const isTarType = contentType === "tar" || contentType === "tgz";
230+
const isSinglePath = paths.length === 1;
231+
const firstStats = isSinglePath
232+
? statsMap.get(resolve(paths[0]))!
233+
: undefined;
234+
const singleIsDir = isSinglePath && firstStats!.isDirectory();
235+
236+
// Multi-path requires tar/tgz content type
237+
if (paths.length > 1 && !isTarType) {
238+
outputError(
239+
"Multiple paths require --content-type tar or --content-type tgz",
240+
);
241+
return;
242+
}
243+
244+
// Directory without tar/tgz type
245+
if (singleIsDir && !isTarType) {
246+
outputError(
247+
"Cannot upload a directory directly. Use --content-type tar or --content-type tgz to create an archive.",
248+
);
249+
return;
250+
}
251+
252+
const shouldCreateArchive =
253+
isTarType && (paths.length > 1 || singleIsDir);
254+
255+
if (shouldCreateArchive) {
256+
const gzip = contentType === "tgz";
257+
fileBuffer = await createTarBuffer(paths, gzip, statsMap);
258+
detectedContentType = contentType as ContentType;
259+
fileSize = fileBuffer.length;
260+
} else {
261+
// Single file upload (existing behavior)
262+
const filePath = paths[0];
263+
fileBuffer = await readFile(filePath);
264+
fileSize = fileBuffer.length;
265+
266+
detectedContentType = contentType as ContentType;
267+
if (!detectedContentType) {
268+
const ext = extname(filePath).toLowerCase();
269+
detectedContentType = CONTENT_TYPE_MAP[ext] || "unspecified";
270+
}
237271
}
238272
}
239273

src/utils/commands.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -652,9 +652,9 @@ export function createProgram(): Command {
652652
object
653653
.command("upload <paths...>")
654654
.description(
655-
"Upload file(s) or directory as an object. Multiple paths with --content-type tar|tgz creates an archive.",
655+
"Upload file(s) or directory as an object. Multiple paths with --content-type tar|tgz creates an archive. Use - to read from stdin.",
656656
)
657-
.option("--name <name>", "Object name (required)")
657+
.option("--name <name>", "Object name (required; mandatory for stdin)")
658658
.option(
659659
"--content-type <type>",
660660
"Content type: unspecified|text|binary|gzip|tar|tgz",

0 commit comments

Comments
 (0)