Skip to content

Commit f34d6c8

Browse files
jason-rlclaude
andcommitted
feat: print pre-signed upload URL when invoked with 0 paths
When `rli object upload --name <name>` is called without any file paths, create the object via the API and print the pre-signed upload URL to stdout. This enables external upload workflows (e.g. curl) matching the frontend's "Copy URL and Close" pattern. The object stays in UPLOADING state until the user uploads and completes externally. - Change `<paths...>` to `[paths...]` (Commander optional variadic) - Skip interactive screen buffer for 0-paths mode - Default content type to "unspecified" when omitted - Add null guard for upload_url from API response Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fd8eef6 commit f34d6c8

4 files changed

Lines changed: 123 additions & 8 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ rli blueprint from-dockerfile # Create a blueprint from a Dockerfile
148148
rli object list # List objects
149149
rli object get <id> # Get object details
150150
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...
151+
rli object upload [paths...] # Upload file(s) or directory as an obj...
152152
rli object delete <id> # Delete an object (irreversible)
153153
```
154154

src/commands/object/upload.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,33 @@ export async function uploadObject(options: UploadObjectOptions) {
175175
const { paths, name, contentType, output: outputFormat } = options;
176176

177177
if (paths.length === 0) {
178-
outputError("At least one path is required");
178+
if (!name) {
179+
outputError("--name is required when no paths are provided");
180+
}
181+
const resolvedContentType: ContentType =
182+
(contentType as ContentType) || "unspecified";
183+
184+
const createResponse = await client.objects.create({
185+
name,
186+
content_type: resolvedContentType,
187+
});
188+
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+
};
199+
200+
if (!outputFormat || outputFormat === "text") {
201+
console.log(createResponse.upload_url);
202+
} else {
203+
output(result, { format: outputFormat, defaultFormat: "json" });
204+
}
179205
return;
180206
}
181207

src/utils/commands.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -650,11 +650,11 @@ export function createProgram(): Command {
650650
});
651651

652652
object
653-
.command("upload <paths...>")
653+
.command("upload [paths...]")
654654
.description(
655-
"Upload file(s) or directory as an object. Multiple paths with --content-type tar|tgz creates an archive. Use - to read from stdin.",
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.",
656656
)
657-
.option("--name <name>", "Object name (required; mandatory for stdin)")
657+
.option("--name <name>", "Object name (required)")
658658
.option(
659659
"--content-type <type>",
660660
"Content type: unspecified|text|binary|gzip|tar|tgz",
@@ -666,12 +666,15 @@ export function createProgram(): Command {
666666
)
667667
.action(async (paths, options) => {
668668
const { uploadObject } = await import("../commands/object/upload.js");
669-
if (!options.output) {
669+
const resolvedPaths = paths || [];
670+
if (!options.output && resolvedPaths.length > 0) {
670671
const { runInteractiveCommand } =
671672
await import("../utils/interactiveCommand.js");
672-
await runInteractiveCommand(() => uploadObject({ paths, ...options }));
673+
await runInteractiveCommand(() =>
674+
uploadObject({ paths: resolvedPaths, ...options }),
675+
);
673676
} else {
674-
await uploadObject({ paths, ...options });
677+
await uploadObject({ paths: resolvedPaths, ...options });
675678
}
676679
});
677680

tests/__tests__/commands/object/upload.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,4 +327,90 @@ describe("uploadObject", () => {
327327
const body = fetchCall[1]?.body as Buffer;
328328
expect(body.toString()).toBe("fake tar content");
329329
});
330+
331+
describe("0-paths mode (URL-only)", () => {
332+
it("creates object and prints upload URL when no paths provided", async () => {
333+
const logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
334+
335+
const { uploadObject } = await import("@/commands/object/upload.js");
336+
await uploadObject({
337+
paths: [],
338+
name: "url-only-object",
339+
contentType: "text",
340+
});
341+
342+
expect(mockCreate).toHaveBeenCalledWith({
343+
name: "url-only-object",
344+
content_type: "text",
345+
});
346+
expect(logSpy).toHaveBeenCalledWith("https://example.com/upload");
347+
expect(mockFetch).not.toHaveBeenCalled();
348+
expect(mockComplete).not.toHaveBeenCalled();
349+
logSpy.mockRestore();
350+
});
351+
352+
it("defaults content type to unspecified when omitted", async () => {
353+
const logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
354+
355+
const { uploadObject } = await import("@/commands/object/upload.js");
356+
await uploadObject({
357+
paths: [],
358+
name: "no-ct-object",
359+
});
360+
361+
expect(mockCreate).toHaveBeenCalledWith({
362+
name: "no-ct-object",
363+
content_type: "unspecified",
364+
});
365+
expect(logSpy).toHaveBeenCalledWith("https://example.com/upload");
366+
logSpy.mockRestore();
367+
});
368+
369+
it("outputs structured result in JSON mode", async () => {
370+
const { uploadObject } = await import("@/commands/object/upload.js");
371+
await uploadObject({
372+
paths: [],
373+
name: "json-url-object",
374+
contentType: "binary",
375+
output: "json",
376+
});
377+
378+
expect(mockOutput).toHaveBeenCalledWith(
379+
{
380+
id: "obj_test123",
381+
name: "json-url-object",
382+
contentType: "binary",
383+
uploadUrl: "https://example.com/upload",
384+
},
385+
{ format: "json", defaultFormat: "json" },
386+
);
387+
expect(mockFetch).not.toHaveBeenCalled();
388+
expect(mockComplete).not.toHaveBeenCalled();
389+
});
390+
391+
it("errors when --name is missing", async () => {
392+
mockOutputError.mockImplementationOnce(() => {
393+
throw new Error("exit");
394+
});
395+
mockOutputError.mockImplementationOnce(() => {
396+
throw new Error("exit");
397+
});
398+
399+
const { uploadObject } = await import("@/commands/object/upload.js");
400+
try {
401+
await uploadObject({
402+
paths: [],
403+
name: "",
404+
});
405+
} catch {
406+
// expected: mockOutputError throws to simulate process.exit
407+
}
408+
409+
expect(mockOutputError).toHaveBeenNthCalledWith(
410+
1,
411+
"--name is required when no paths are provided",
412+
);
413+
expect(mockCreate).not.toHaveBeenCalled();
414+
});
415+
});
330416
});

0 commit comments

Comments
 (0)