Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,13 @@ Read a small JSON result:
putio files list --per-page 5 --fields files,total --output json
```

Upload a local file:

```bash
putio files upload --path ./movie.mp4 --parent-id 42 --dry-run --output json
putio files upload --path ./movie.mp4 --parent-id 42 --output json
```

Read or update a saved watch position:

```bash
Expand Down
1 change: 1 addition & 0 deletions skills/putio-cli/references/guardrails.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Input safety notes:
- resource identifiers reject query fragments and traversal-like segments
- field selectors reject nested paths and malformed tokens
- name-like inputs reject control characters and traversal-like segments
- local upload paths reject control characters and must resolve to readable regular files

Output safety notes:

Expand Down
3 changes: 3 additions & 0 deletions skills/putio-cli/references/writes.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Dry-run first:
```bash
putio transfers cancel --json '{"ids":[12,18]}' --dry-run --output json
putio files rename --json '{"file_id":42,"name":"Projects 2027"}' --dry-run --output json
putio files upload --json '{"path":"./movie.mp4","parent_id":42}' --dry-run --output json
putio files start-from set --json '{"file_id":42,"time":95}' --dry-run --output json
putio auth approve --json '{"code":"PUTIO1"}' --dry-run --output json
```
Expand All @@ -18,6 +19,7 @@ Examples:
```bash
putio download-links create --json '{"ids":[1,2]}' --output json
putio files mkdir --json '{"name":"Projects","parent_id":9}' --output json
putio files upload --json '{"path":"./movie.mp4","parent_id":42}' --output json
putio files start-from reset --json '{"file_id":42}' --output json
putio transfers add --json '[{"url":"https://example.com/file.torrent"}]' --output json
```
Expand All @@ -27,3 +29,4 @@ Rules:
- Prefer `--json` over translating through many bespoke flags.
- Prefer `--dry-run` before side effects.
- Re-check schema-required keys in `describe` instead of guessing names.
- `files upload` validates that `path` resolves to a readable regular file before dry-run or execution.
8 changes: 8 additions & 0 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ describe("cli argv parsing", () => {
}>;
const mkdir = commands.find((entry) => entry.command === "files mkdir");
const deleteFiles = commands.find((entry) => entry.command === "files delete");
const upload = commands.find((entry) => entry.command === "files upload");
const authApprove = commands.find((entry) => entry.command === "auth approve");
const startFromSet = commands.find((entry) => entry.command === "files start-from set");

Expand All @@ -201,6 +202,13 @@ describe("cli argv parsing", () => {
expect.objectContaining({ name: "skip_trash", required: false }),
]),
);
expect(upload?.input.json?.properties).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "path", required: true }),
expect.objectContaining({ name: "file_name", required: false }),
expect.objectContaining({ name: "parent_id", required: false }),
]),
);
expect(authApprove?.input.json?.properties).toEqual([
expect.objectContaining({ name: "code", required: true }),
]);
Expand Down
201 changes: 200 additions & 1 deletion src/command-paths.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,29 @@
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
import { Effect } from "effect";

import { resetCommandPathMocks } from "./test-support/command-path-mocks.js";
import { runCliInTest } from "./test-support/run-cli.js";

const uploadFixtureDirectories = new Set<string>();

const makeUploadFixtureDirectory = async () => {
const directory = await mkdtemp(join(tmpdir(), "putio-cli-upload-"));
uploadFixtureDirectories.add(directory);
return directory;
};

afterEach(async () => {
const directories = [...uploadFixtureDirectories];
uploadFixtureDirectories.clear();
await Promise.all(
directories.map((directory) => rm(directory, { force: true, recursive: true })),
);
});

const mocks = vi.hoisted(() => {
type FileListItem = {
readonly file_type?: string;
Expand Down Expand Up @@ -141,6 +161,12 @@ const mocks = vi.hoisted(() => {
const continueSearchFilesMock = vi.fn((_cursor?: string) => Effect.succeed(emptyFileListPage));
const listFilesMock = vi.fn(() => Effect.succeed(defaultFileListPage));
const searchFilesMock = vi.fn(() => Effect.succeed(defaultSearchFilesPage));
const uploadFileMock = vi.fn(() =>
Effect.succeed({
file: { id: 88, name: "movie.mp4" },
type: "file" as const,
}),
);
const getStartFromMock = vi.fn(() => Effect.succeed(90));
const setStartFromMock = vi.fn(() => Effect.succeed({ status: "OK" }));
const resetStartFromMock = vi.fn(() => Effect.succeed({ status: "OK" }));
Expand Down Expand Up @@ -296,6 +322,7 @@ const mocks = vi.hoisted(() => {
rename: renameFileMock,
resetStartFrom: resetStartFromMock,
search: searchFilesMock,
upload: uploadFileMock,
setStartFrom: setStartFromMock,
},
transfers: {
Expand Down Expand Up @@ -348,6 +375,7 @@ const mocks = vi.hoisted(() => {
searchFilesMock,
setStartFromMock,
useProfileMock,
uploadFileMock,
waitForDeviceTokenMock,
withAuthedSdkMock,
withTerminalLoaderMock,
Expand Down Expand Up @@ -1099,6 +1127,177 @@ describe("cli command paths", () => {
);
});

it("uploads a readable local file with the mocked sdk", async () => {
const directory = await makeUploadFixtureDirectory();
const path = join(directory, "movie.mp4");
await writeFile(path, "video fixture");

await expect(
runCliInTest([
"putio",
"files",
"upload",
"--path",
path,
"--parent-id",
"42",
"--output",
"json",
]),
).resolves.toBeUndefined();

expect(mocks.uploadFileMock).toHaveBeenCalledWith({
file: expect.any(Blob),
fileName: "movie.mp4",
parentId: 42,
});
expect(mocks.writeOutputMock).toHaveBeenCalledWith(
{
file: { id: 88, name: "movie.mp4" },
type: "file",
},
"json",
expect.any(Function),
);
});

it("previews a local file upload from raw json without hitting the sdk", async () => {
const directory = await makeUploadFixtureDirectory();
const path = join(directory, "movie.mp4");
await writeFile(path, "video fixture");

await expect(
runCliInTest([
"putio",
"files",
"upload",
"--json",
JSON.stringify({ file_name: "fixture.mp4", parent_id: 42, path }),
"--dry-run",
"--output",
"json",
]),
).resolves.toBeUndefined();

expect(mocks.uploadFileMock).not.toHaveBeenCalled();
expect(mocks.writeOutputMock).toHaveBeenCalledWith(
{
command: "files upload",
dryRun: true,
request: {
file_name: "fixture.mp4",
parent_id: 42,
path,
size: 13,
},
},
"json",
expect.any(Function),
);
});

it("rejects directory paths before file upload", async () => {
const directory = await makeUploadFixtureDirectory();

await expect(
runCliInTest(["putio", "files", "upload", "--path", directory, "--output", "json"]),
).rejects.toMatchObject({
message: "Expected the upload path to point to a regular file.",
});

expect(mocks.uploadFileMock).not.toHaveBeenCalled();
});

it.skipIf(typeof process.getuid === "function" && process.getuid() === 0)(
"rejects unreadable files before previewing an upload",
async () => {
const directory = await makeUploadFixtureDirectory();
const path = join(directory, "private.mp4");
await writeFile(path, "video fixture", { mode: 0o000 });

await expect(
runCliInTest(["putio", "files", "upload", "--path", path, "--dry-run", "--output", "json"]),
).rejects.toMatchObject({
message:
"Unable to read the local upload file. Verify that the path exists and is readable.",
});

expect(mocks.uploadFileMock).not.toHaveBeenCalled();
},
);

it("rejects a blank upload filename before hitting the sdk", async () => {
const directory = await makeUploadFixtureDirectory();
const path = join(directory, "movie.mp4");
await writeFile(path, "video fixture");

await expect(
runCliInTest([
"putio",
"files",
"upload",
"--path",
path,
"--file-name",
" ",
"--output",
"json",
]),
).rejects.toMatchObject({
message: "Expected `files upload --file-name` to be a non-empty string.",
});

expect(mocks.uploadFileMock).not.toHaveBeenCalled();
});

it("rejects invalid upload options before filesystem work", async () => {
await expect(
runCliInTest([
"putio",
"files",
"upload",
"--path",
"/missing/upload-fixture",
"--file-name",
"../movie.mp4",
"--output",
"json",
]),
).rejects.toMatchObject({
message:
"`files upload --file-name` cannot contain path traversal segments like `../` or `%2e`.",
});

await expect(
runCliInTest([
"putio",
"files",
"upload",
"--json",
'{"path":"/missing/upload-fixture","parent_id":-1}',
"--output",
"json",
]),
).rejects.toMatchObject({
message: "Expected `--json` to match the command input schema.",
});

expect(mocks.uploadFileMock).not.toHaveBeenCalled();
});

it("propagates sdk upload failures", async () => {
const directory = await makeUploadFixtureDirectory();
const path = join(directory, "private.mp4");
await writeFile(path, "video fixture");
mocks.uploadFileMock.mockImplementationOnce(() => Effect.fail(new Error("upload failed")));

await expect(
runCliInTest(["putio", "files", "upload", "--path", path, "--output", "json"]),
).rejects.toThrow("upload failed");

expect(mocks.uploadFileMock).toHaveBeenCalledOnce();
});

it("executes files delete with repeated ids", async () => {
await expect(
runCliInTest([
Expand Down
16 changes: 16 additions & 0 deletions src/commands/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { renderFilesTerminal } from "../internal/terminal/files-terminal.js";
import {
renderFileCreatedTerminal,
renderFileRenamedTerminal,
renderFileUploadedTerminal,
renderFilesDeletedTerminal,
renderFilesMovedTerminal,
} from "./files.js";
Expand Down Expand Up @@ -56,6 +57,21 @@ describe("file mutation renderers", () => {
).toBe('renamed file 42 to "Projects 2026"');
});

it("renders direct and queued upload feedback", () => {
expect(
renderFileUploadedTerminal({
file: { id: 88, name: "movie.mp4" },
type: "file",
}),
).toBe('uploaded file "movie.mp4" (id 88)');
expect(
renderFileUploadedTerminal({
transfer: { id: 89, name: "archive.zip" },
type: "transfer",
}),
).toBe('queued upload transfer "archive.zip" (id 89)');
});

it("renders delete feedback", () => {
expect(
renderFilesDeletedTerminal({
Expand Down
Loading