Skip to content

Commit dcc36f0

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 dcc36f0

2 files changed

Lines changed: 81 additions & 55 deletions

File tree

src/commands/object/upload.ts

Lines changed: 79 additions & 53 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,87 @@ 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-
}
192-
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) {
202-
outputError(
203-
"Multiple paths require --content-type tar or --content-type tgz",
204-
);
205-
return;
206-
}
182+
const isStdin = paths.length === 1 && paths[0] === "-";
207183

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;
184+
if (isStdin) {
185+
if (!name) {
186+
outputError("--name is required when uploading from stdin");
187+
}
188+
if (!contentType) {
189+
outputError("--content-type is required when uploading from stdin");
190+
}
214191
}
215192

216193
let fileBuffer: Buffer;
217194
let detectedContentType: ContentType;
218195
let fileSize: number;
219196

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;
197+
if (isStdin) {
198+
fileBuffer = await readStdinBuffer();
226199
fileSize = fileBuffer.length;
200+
detectedContentType = contentType as ContentType;
227201
} else {
228-
// Single file upload (existing behavior)
229-
const filePath = paths[0];
230-
fileBuffer = await readFile(filePath);
231-
fileSize = fileBuffer.length;
202+
// Validate all paths exist (use lstat to match collectEntries and detect symlinks)
203+
// Key by resolved absolute path so collectEntries can reuse stats
204+
const statsMap = new Map<string, Awaited<ReturnType<typeof lstat>>>();
205+
for (const p of paths) {
206+
try {
207+
const s = await lstat(p);
208+
if (s.isSymbolicLink()) {
209+
outputError(
210+
`Path is a symlink: ${p}. Resolve the symlink or pass the target path directly.`,
211+
);
212+
return;
213+
}
214+
statsMap.set(resolve(p), s);
215+
} catch {
216+
outputError(`Path does not exist: ${p}`);
217+
return;
218+
}
219+
}
232220

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

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)