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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,12 +131,27 @@ putio auth profiles list --output json
putio auth profiles remove devs-fe-auto
```

Approve a code displayed by another device:

```bash
putio auth approve PUTIO1 --dry-run --output json
putio auth approve PUTIO1 --output json
```

Read a small JSON result:

```bash
putio files list --per-page 5 --fields files,total --output json
```

Read or update a saved watch position:

```bash
putio files start-from get 42 --output json
putio files start-from set 42 95 --dry-run --output json
putio files start-from reset 42 --dry-run --output json
```

Stream larger reads:

```bash
Expand Down
9 changes: 8 additions & 1 deletion skills/putio-cli/references/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,19 @@ For interactive login:
putio auth login
```

For put.io device approval or previewing a device link without logging in:
Preview a device-link URL without requesting or approving a real code:

```bash
putio auth preview --code PUTIO1 --output json
```

Approve a code displayed by another device with the authenticated account:

```bash
putio auth approve PUTIO1 --dry-run --output json
putio auth approve PUTIO1 --output json
```

List or remove named profiles:

```bash
Expand Down
2 changes: 2 additions & 0 deletions skills/putio-cli/references/reads.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Use `--fields` with top-level keys only:
```bash
putio whoami --fields auth --output json
putio files list --fields files,total --output json
putio files start-from get 42 --fields start_from --output json
```

Use `--page-all` only when the command advertises it and you truly need every page:
Expand Down Expand Up @@ -42,3 +43,4 @@ Notes:
- `--fields` is only for top-level response keys.
- `--fields` requires structured output.
- `events list` supports `--fields`, but not `--page-all`.
- `files start-from get` returns `file_id` and `start_from` in seconds.
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,8 @@ 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 start-from set --json '{"file_id":42,"time":95}' --dry-run --output json
putio auth approve --json '{"code":"PUTIO1"}' --dry-run --output json
```

Execute for real only after the dry-run request shape looks correct.
Expand All @@ -16,6 +18,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 start-from reset --json '{"file_id":42}' --output json
putio transfers add --json '[{"url":"https://example.com/file.torrent"}]' --output json
```

Expand Down
11 changes: 11 additions & 0 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,8 @@ describe("cli argv parsing", () => {
}>;
const mkdir = commands.find((entry) => entry.command === "files mkdir");
const deleteFiles = commands.find((entry) => entry.command === "files delete");
const authApprove = commands.find((entry) => entry.command === "auth approve");
const startFromSet = commands.find((entry) => entry.command === "files start-from set");

expect(mkdir?.input.json?.properties).toEqual(
expect.arrayContaining([
Expand All @@ -199,6 +201,15 @@ describe("cli argv parsing", () => {
expect.objectContaining({ name: "skip_trash", required: false }),
]),
);
expect(authApprove?.input.json?.properties).toEqual([
expect.objectContaining({ name: "code", required: true }),
]);
expect(startFromSet?.input.json?.properties).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "file_id", required: true }),
expect.objectContaining({ name: "time", required: true }),
]),
);
});

it("accepts an explicit output mode on describe", async () => {
Expand Down
154 changes: 154 additions & 0 deletions src/command-paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ const mocks = vi.hoisted(() => {
const provideSdkMock = vi.fn((_config, program) => program);
const getCodeMock = vi.fn(() => Effect.succeed({ code: "PUTIO1" }));
const checkCodeMatchMock = vi.fn(() => Effect.succeed("token-123"));
const linkDeviceMock = vi.fn(() =>
Effect.succeed({
description: "Living room TV",
has_icon: false,
id: 77,
name: "put.io TV",
website: "https://put.io",
}),
);
const continueTransfersMock = vi.fn((_cursor?: string) => Effect.succeed(emptyTransferListPage));
const listTransfersMock = vi.fn(() => Effect.succeed(defaultTransferListPage));
const addTransfersMock = vi.fn(() =>
Expand Down Expand Up @@ -132,6 +141,9 @@ 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 getStartFromMock = vi.fn(() => Effect.succeed(90));
const setStartFromMock = vi.fn(() => Effect.succeed({ status: "OK" }));
const resetStartFromMock = vi.fn(() => Effect.succeed({ status: "OK" }));
const getAccountInfoMock = vi.fn(() =>
Effect.succeed({
account_status: "ACTIVE",
Expand Down Expand Up @@ -264,6 +276,7 @@ const mocks = vi.hoisted(() => {
auth: {
checkCodeMatch: checkCodeMatchMock,
getCode: getCodeMock,
linkDevice: linkDeviceMock,
},
downloadLinks: {
create: createDownloadLinksMock,
Expand All @@ -277,10 +290,13 @@ const mocks = vi.hoisted(() => {
continueSearch: continueSearchFilesMock,
createFolder: createFolderMock,
delete: deleteFilesMock,
getStartFrom: getStartFromMock,
list: listFilesMock,
move: moveFilesMock,
rename: renameFileMock,
resetStartFrom: resetStartFromMock,
search: searchFilesMock,
setStartFrom: setStartFromMock,
},
transfers: {
addMany: addTransfersMock,
Expand Down Expand Up @@ -311,22 +327,26 @@ const mocks = vi.hoisted(() => {
getAuthStatusMock,
checkCodeMatchMock,
getCodeMock,
getStartFromMock,
getTransferMock,
listEventsMock,
listFilesMock,
listProfilesMock,
listTransfersMock,
linkDeviceMock,
moveFilesMock,
openBrowserMock,
provideSdkMock,
renameFileMock,
resetStartFromMock,
reannounceTransferMock,
removeProfileMock,
resolveAuthFlowConfigMock,
resolveCliRuntimeConfigMock,
retryTransferMock,
savePersistedStateMock,
searchFilesMock,
setStartFromMock,
useProfileMock,
waitForDeviceTokenMock,
withAuthedSdkMock,
Expand Down Expand Up @@ -612,6 +632,45 @@ describe("cli command paths", () => {
).toContain("HELLO1");
});

it("approves a device code with the authenticated account", async () => {
await expect(
runCliInTest(["putio", "auth", "approve", "HELLO1", "--output", "json"]),
).resolves.toBeUndefined();

expect(mocks.linkDeviceMock).toHaveBeenCalledWith("HELLO1");
expect(mocks.writeOutputMock).toHaveBeenCalledWith(
expect.objectContaining({ id: 77, name: "put.io TV" }),
"json",
expect.any(Function),
);
});

it("previews device approval from raw json without hitting the sdk", async () => {
await expect(
runCliInTest([
"putio",
"auth",
"approve",
"--json",
'{"code":" HELLO1 "}',
"--dry-run",
"--output",
"json",
]),
).resolves.toBeUndefined();

expect(mocks.linkDeviceMock).not.toHaveBeenCalled();
expect(mocks.writeOutputMock).toHaveBeenCalledWith(
{
command: "auth approve",
dryRun: true,
request: { code: "HELLO1" },
},
"json",
expect.any(Function),
);
});

it("executes auth logout", async () => {
await expect(
runCliInTest(["putio", "auth", "logout", "--output", "json"]),
Expand Down Expand Up @@ -699,6 +758,16 @@ describe("cli command paths", () => {
expect(mocks.writeOutputMock).not.toHaveBeenCalled();
});

it("rejects device approval codes with query fragments", async () => {
await expect(
runCliInTest(["putio", "auth", "approve", "PUTIO1?debug=1", "--output", "json"]),
).rejects.toMatchObject({
message: "`auth approve` code cannot include `?` or `#` fragments.",
});

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

it("executes whoami", async () => {
await expect(runCliInTest(["putio", "whoami", "--output", "json"])).resolves.toBeUndefined();

Expand Down Expand Up @@ -945,6 +1014,91 @@ describe("cli command paths", () => {
);
});

it("reads a file watch position", async () => {
await expect(
runCliInTest([
"putio",
"files",
"start-from",
"get",
"42",
"--fields",
"start_from",
"--output",
"json",
]),
).resolves.toBeUndefined();

expect(mocks.getStartFromMock).toHaveBeenCalledWith(42);
expect(mocks.writeOutputMock).toHaveBeenCalledWith(
{ start_from: 90 },
"json",
expect.any(Function),
);
});

it("sets a file watch position", async () => {
await expect(
runCliInTest(["putio", "files", "start-from", "set", "42", "95", "--output", "json"]),
).resolves.toBeUndefined();

expect(mocks.setStartFromMock).toHaveBeenCalledWith({ file_id: 42, time: 95 });
expect(mocks.writeOutputMock).toHaveBeenCalledWith(
{ file_id: 42, start_from: 95, status: "OK" },
"json",
expect.any(Function),
);
});

it("resets a file watch position from raw json", async () => {
await expect(
runCliInTest([
"putio",
"files",
"start-from",
"reset",
"--json",
'{"file_id":42}',
"--output",
"json",
]),
).resolves.toBeUndefined();

expect(mocks.resetStartFromMock).toHaveBeenCalledWith(42);
expect(mocks.writeOutputMock).toHaveBeenCalledWith(
{ file_id: 42, start_from: 0, status: "OK" },
"json",
expect.any(Function),
);
});

it("previews a watch-position update without hitting the sdk", async () => {
await expect(
runCliInTest([
"putio",
"files",
"start-from",
"set",
"--json",
'{"file_id":42,"time":95}',
"--dry-run",
"--output",
"json",
]),
).resolves.toBeUndefined();

expect(mocks.setStartFromMock).not.toHaveBeenCalled();
expect(mocks.writeOutputMock).toHaveBeenCalledWith(
{
command: "files start-from set",
dryRun: true,
request: { file_id: 42, time: 95 },
},
"json",
expect.any(Function),
);
});

it("executes files delete with repeated ids", async () => {
await expect(
runCliInTest([
Expand Down
Loading