diff --git a/README.md b/README.md index 67b330c..21505e8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/skills/putio-cli/references/auth.md b/skills/putio-cli/references/auth.md index 3238931..18c5e30 100644 --- a/skills/putio-cli/references/auth.md +++ b/skills/putio-cli/references/auth.md @@ -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 diff --git a/skills/putio-cli/references/reads.md b/skills/putio-cli/references/reads.md index acd4145..4db8718 100644 --- a/skills/putio-cli/references/reads.md +++ b/skills/putio-cli/references/reads.md @@ -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: @@ -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. diff --git a/skills/putio-cli/references/writes.md b/skills/putio-cli/references/writes.md index 9f5afb2..65e6698 100644 --- a/skills/putio-cli/references/writes.md +++ b/skills/putio-cli/references/writes.md @@ -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. @@ -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 ``` diff --git a/src/cli.test.ts b/src/cli.test.ts index ca18c4b..8d7c14d 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -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([ @@ -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 () => { diff --git a/src/command-paths.test.ts b/src/command-paths.test.ts index 9f20f27..64a0a59 100644 --- a/src/command-paths.test.ts +++ b/src/command-paths.test.ts @@ -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(() => @@ -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", @@ -264,6 +276,7 @@ const mocks = vi.hoisted(() => { auth: { checkCodeMatch: checkCodeMatchMock, getCode: getCodeMock, + linkDevice: linkDeviceMock, }, downloadLinks: { create: createDownloadLinksMock, @@ -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, @@ -311,15 +327,18 @@ const mocks = vi.hoisted(() => { getAuthStatusMock, checkCodeMatchMock, getCodeMock, + getStartFromMock, getTransferMock, listEventsMock, listFilesMock, listProfilesMock, listTransfersMock, + linkDeviceMock, moveFilesMock, openBrowserMock, provideSdkMock, renameFileMock, + resetStartFromMock, reannounceTransferMock, removeProfileMock, resolveAuthFlowConfigMock, @@ -327,6 +346,7 @@ const mocks = vi.hoisted(() => { retryTransferMock, savePersistedStateMock, searchFilesMock, + setStartFromMock, useProfileMock, waitForDeviceTokenMock, withAuthedSdkMock, @@ -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"]), @@ -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(); @@ -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([ diff --git a/src/commands/auth.ts b/src/commands/auth.ts index f5f2f31..fda42a6 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,6 +1,6 @@ import { Argument, Command } from "effect/unstable/cli"; import * as Terminal from "effect/Terminal"; -import { Cause, Console, Effect, Fiber, Option, Queue } from "effect"; +import { Cause, Console, Effect, Fiber, Option, Queue, Schema } from "effect"; import { translate } from "../i18n/index.js"; import { @@ -17,12 +17,24 @@ import { defineBooleanOption, defineIntegerOption, defineTextOption, + dryRunOption, getOption, + jsonOption, outputOption, + resolveMutationInput, validateResourceIdentifier, + withAuthedSdk, + writeDryRunPlan, CliCommandInputError, } from "../internal/command.js"; -import { outputFlag, stringArgument, type CommandSpec } from "../internal/command-specs.js"; +import { + dryRunFlag, + jsonFlag, + jsonShapeFromSchema, + outputFlag, + stringArgument, + type CommandSpec, +} from "../internal/command-specs.js"; import type { CliConfig } from "../internal/config.js"; import { resolveCliRuntimeConfig } from "../internal/config.js"; import { withTerminalLoader } from "../internal/loader-service.js"; @@ -58,11 +70,22 @@ const timeoutSecondsOption = timeoutSecondsConfig.option; const previewCodeOption = previewCodeConfig.option; const profileOption = profileConfig.option; const profileArgument = Argument.string("profile"); +const approveCodeArgument = Argument.string("code").pipe(Argument.optional); const profileCommandArgument = stringArgument("profile", { description: AUTH_PROFILE_NAME_DESCRIPTION, required: true, }); +const NonBlankStringSchema = Schema.String.check( + Schema.makeFilter((value) => + value.trim().length > 0 ? undefined : "Expected a non-empty string", + ), +); + +const AuthApproveInputSchema = Schema.Struct({ + code: NonBlankStringSchema, +}); + type AuthCommandEnvironment = | Command.Environment | CliConfig @@ -318,6 +341,45 @@ const authPreview = Command.make( }), ); +const authApprove = Command.make( + "approve", + { + code: approveCodeArgument, + dryRun: dryRunOption, + json: jsonOption, + output: outputOption, + }, + ({ code, dryRun, json, output }) => + Effect.gen(function* () { + const input = yield* resolveMutationInput({ + buildFromFlags: () => { + const value = getOption(code); + + if (value === undefined) { + throw new CliCommandInputError({ + message: "Provide a device code or `--json` for `auth approve`.", + }); + } + + return { code: value }; + }, + json, + schema: AuthApproveInputSchema, + }); + const approvedCode = validateResourceIdentifier("`auth approve` code", input.code.trim()); + + if (dryRun) { + return yield* writeDryRunPlan("auth approve", { code: approvedCode }, getOption(output)); + } + + const result = yield* withAuthedSdk(({ sdk }) => sdk.auth.linkDevice(approvedCode)); + + yield* writeOutput(result, getOption(output), (value) => + translate("cli.auth.approve.approved", { id: value.id, name: value.name }), + ); + }), +); + const authProfilesList = Command.make("list", { output: outputOption }, ({ output }) => Effect.gen(function* () { const result = yield* listProfiles(); @@ -375,10 +437,39 @@ const authProfiles = Command.make("profiles", {}, () => Effect.void).pipe( export const makeAuthCommand = (): AuthCommand => Command.make("auth", {}, () => Console.log(translate("cli.root.chooseAuthSubcommand"))).pipe( - Command.withSubcommands([authStatus, authLogin, authLogout, authPreview, authProfiles]), + Command.withSubcommands([ + authStatus, + authLogin, + authLogout, + authPreview, + authApprove, + authProfiles, + ]), ); export const authCommandSpecs = [ + { + auth: { required: true }, + capabilities: { + dryRun: true, + fieldSelection: false, + rawJsonInput: true, + streaming: false, + }, + command: "auth approve", + input: { + arguments: [ + stringArgument("code", { + description: "Short code displayed by the device requesting authorization.", + required: false, + }), + ], + flags: [dryRunFlag(), jsonFlag(), outputFlag()], + json: jsonShapeFromSchema(AuthApproveInputSchema), + }, + kind: "write", + purpose: translate("cli.metadata.authApprove"), + }, { auth: { required: false }, capabilities: { diff --git a/src/commands/files.ts b/src/commands/files.ts index 117e419..4968773 100644 --- a/src/commands/files.ts +++ b/src/commands/files.ts @@ -1,4 +1,4 @@ -import { Command } from "effect/unstable/cli"; +import { Argument, Command } from "effect/unstable/cli"; import { Effect, Option, Schema } from "effect"; import { @@ -19,11 +19,13 @@ import { validateNameLikeInput, withAuthedSdk, writeDryRunPlan, + writeReadOutput, writeReadPages, } from "../internal/command.js"; import { dryRunFlag, fieldsFlag, + integerArgument, jsonFlag, jsonShapeFromSchema, outputFlag, @@ -82,6 +84,9 @@ const fileTypeOption = fileTypeConfig.option; const sortByOption = sortByConfig.option; const optionalFileIdOption = optionalFileIdConfig.option; const optionalFileNameOption = optionalFileNameConfig.option; +const startFromFileIdArgument = Argument.integer("file-id"); +const optionalStartFromFileIdArgument = startFromFileIdArgument.pipe(Argument.optional); +const optionalStartFromTimeArgument = Argument.integer("seconds").pipe(Argument.optional); const NonBlankStringSchema = Schema.String.check( Schema.makeFilter((value) => @@ -90,6 +95,8 @@ const NonBlankStringSchema = Schema.String.check( ); const NonEmptyIdsSchema = Schema.Array(Schema.Number).check(Schema.isNonEmpty()); +const PositiveIntegerSchema = Schema.Int.check(Schema.isGreaterThan(0)); +const NonNegativeIntegerSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); const FilesMkdirInputSchema = Schema.Struct({ name: NonBlankStringSchema, @@ -111,6 +118,15 @@ const FilesMoveInputSchema = Schema.Struct({ parent_id: Schema.Number, }); +const FilesStartFromSetInputSchema = Schema.Struct({ + file_id: PositiveIntegerSchema, + time: NonNegativeIntegerSchema, +}); + +const FilesStartFromResetInputSchema = Schema.Struct({ + file_id: PositiveIntegerSchema, +}); + const requiredValue = (value: A | undefined, message: string) => { if (value === undefined) { throw new CliCommandInputError({ message }); @@ -135,6 +151,22 @@ const requiredIds = (value: ReadonlyArray, message: string) => { return value; }; +const requiredPositiveInteger = (value: number | undefined, message: string) => { + if (value === undefined || !Number.isInteger(value) || value <= 0) { + throw new CliCommandInputError({ message }); + } + + return value; +}; + +const requiredNonNegativeInteger = (value: number | undefined, message: string) => { + if (value === undefined || !Number.isInteger(value) || value < 0) { + throw new CliCommandInputError({ message }); + } + + return value; +}; + export const renderFileCreatedTerminal = (value: { readonly id: number; readonly name: string; @@ -478,6 +510,141 @@ const filesSearchCommand = Command.make( }), ); +const filesStartFromGet = Command.make( + "get", + { + fields: fieldsOption, + fileId: startFromFileIdArgument, + output: outputOption, + }, + ({ fields, fileId, output }) => + Effect.gen(function* () { + const validatedFileId = requiredPositiveInteger( + fileId, + "Expected `files start-from get` file-id to be a positive integer.", + ); + const controls = yield* resolveReadOutputControls({ + fields, + output: getOption(output), + }); + const startFrom = yield* withTerminalLoader( + { + message: translate("cli.files.command.loadingStartFrom", { fileId: validatedFileId }), + output: controls.output, + }, + withAuthedSdk(({ sdk }) => sdk.files.getStartFrom(validatedFileId)), + ); + const result = { + file_id: validatedFileId, + start_from: startFrom, + }; + + yield* writeReadOutput({ + command: "files start-from get", + output: controls.output, + outputMode: controls.outputMode, + renderTerminalValue: (value) => + translate("cli.files.terminal.startFrom", { + fileId: value.file_id, + seconds: value.start_from, + }), + requestedFields: controls.requestedFields, + value: result, + }); + }), +); + +const filesStartFromSet = Command.make( + "set", + { + dryRun: dryRunOption, + fileId: optionalStartFromFileIdArgument, + json: jsonOption, + output: outputOption, + seconds: optionalStartFromTimeArgument, + }, + ({ dryRun, fileId, json, output, seconds }) => + Effect.gen(function* () { + const input = yield* resolveMutationInput({ + buildFromFlags: () => ({ + file_id: requiredPositiveInteger( + getOption(fileId), + "Provide a positive file-id or `--json` for `files start-from set`.", + ), + time: requiredNonNegativeInteger( + getOption(seconds), + "Provide non-negative seconds or `--json` for `files start-from set`.", + ), + }), + json, + schema: FilesStartFromSetInputSchema, + }); + + if (dryRun) { + return yield* writeDryRunPlan("files start-from set", input, getOption(output)); + } + + const result = yield* withAuthedSdk(({ sdk }) => sdk.files.setStartFrom(input)); + + yield* writeOutput( + { + file_id: input.file_id, + start_from: input.time, + ...result, + }, + getOption(output), + () => + translate("cli.files.terminal.startFromSet", { + fileId: input.file_id, + seconds: input.time, + }), + ); + }), +); + +const filesStartFromReset = Command.make( + "reset", + { + dryRun: dryRunOption, + fileId: optionalStartFromFileIdArgument, + json: jsonOption, + output: outputOption, + }, + ({ dryRun, fileId, json, output }) => + Effect.gen(function* () { + const input = yield* resolveMutationInput({ + buildFromFlags: () => ({ + file_id: requiredPositiveInteger( + getOption(fileId), + "Provide a positive file-id or `--json` for `files start-from reset`.", + ), + }), + json, + schema: FilesStartFromResetInputSchema, + }); + + if (dryRun) { + return yield* writeDryRunPlan("files start-from reset", input, getOption(output)); + } + + const result = yield* withAuthedSdk(({ sdk }) => sdk.files.resetStartFrom(input.file_id)); + + yield* writeOutput( + { + file_id: input.file_id, + start_from: 0, + ...result, + }, + getOption(output), + () => translate("cli.files.terminal.startFromReset", { fileId: input.file_id }), + ); + }), +); + +const filesStartFrom = Command.make("start-from", {}, () => Effect.void).pipe( + Command.withSubcommands([filesStartFromGet, filesStartFromSet, filesStartFromReset]), +); + export const searchCommand = filesSearchCommand; export const filesCommand = Command.make("files", {}, () => Effect.void).pipe( @@ -488,10 +655,64 @@ export const filesCommand = Command.make("files", {}, () => Effect.void).pipe( filesRename, filesMove, filesDelete, + filesStartFrom, ]), ); export const filesCommandSpecs = [ + { + auth: { required: true }, + capabilities: { + dryRun: false, + fieldSelection: true, + rawJsonInput: false, + streaming: false, + }, + command: "files start-from get", + input: { + arguments: [integerArgument("file-id")], + flags: [fieldsFlag(), outputFlag()], + }, + kind: "read", + purpose: translate("cli.metadata.filesStartFromGet"), + }, + { + auth: { required: true }, + capabilities: { + dryRun: true, + fieldSelection: false, + rawJsonInput: true, + streaming: false, + }, + command: "files start-from set", + input: { + arguments: [ + integerArgument("file-id", { required: false }), + integerArgument("seconds", { required: false }), + ], + flags: [dryRunFlag(), jsonFlag(), outputFlag()], + json: jsonShapeFromSchema(FilesStartFromSetInputSchema), + }, + kind: "write", + purpose: translate("cli.metadata.filesStartFromSet"), + }, + { + auth: { required: true }, + capabilities: { + dryRun: true, + fieldSelection: false, + rawJsonInput: true, + streaming: false, + }, + command: "files start-from reset", + input: { + arguments: [integerArgument("file-id", { required: false })], + flags: [dryRunFlag(), jsonFlag(), outputFlag()], + json: jsonShapeFromSchema(FilesStartFromResetInputSchema), + }, + kind: "write", + purpose: translate("cli.metadata.filesStartFromReset"), + }, { auth: { required: true }, capabilities: { diff --git a/src/i18n/catalog/en.ts b/src/i18n/catalog/en.ts index d7c64cc..386bfa6 100644 --- a/src/i18n/catalog/en.ts +++ b/src/i18n/catalog/en.ts @@ -41,6 +41,9 @@ export const en = { }, cli: { auth: { + approve: { + approved: 'approved device link for "{{name}}" (app id {{id}})', + }, login: { activationCode: "activation code", autoOpened: "opened automatically in your browser", @@ -216,6 +219,7 @@ export const en = { creatingFolder: 'Creating folder "{{name}}"...', deleting: "Deleting {{count}} file(s)...", loading: "Loading files...", + loadingStartFrom: "Loading watch position for file {{fileId}}...", moving: "Moving {{count}} file(s) to parent {{parentId}}...", renaming: 'Renaming file {{id}} to "{{name}}"...', searching: 'Searching files for "{{query}}"...', @@ -231,12 +235,16 @@ export const en = { renamed: 'renamed file {{fileId}} to "{{name}}"', skipTrashEnabled: "skip trash: yes", skipped: "skipped: {{count}}", + startFrom: "file {{fileId}} starts from {{seconds}} second(s)", + startFromReset: "reset watch position for file {{fileId}}", + startFromSet: "set watch position for file {{fileId}} to {{seconds}} second(s)", summary: "Showing {{count}} file(s){{totalSuffix}}.", summaryInParent: "Showing {{count}} file(s) in {{name}}{{totalSuffix}}.", totalSuffix: " ({{total}} total)", }, }, metadata: { + authApprove: "Approve a pending device-link code with the authenticated account.", authLogin: "Authorize the CLI through the put.io device-link flow and persist the resulting token.", authLogout: "Remove the persisted CLI auth state.", @@ -256,6 +264,9 @@ export const en = { filesMove: "Move one or more files to a parent directory.", filesRename: "Rename a file by id.", filesSearch: "Search files by query and optional file type.", + filesStartFromGet: "Read the saved watch position for a file.", + filesStartFromReset: "Reset the saved watch position for a file.", + filesStartFromSet: "Set the saved watch position for a file.", search: "Top-level alias for file search.", transfersAdd: "Add one or more transfers from URLs or magnet links.", transfersCancel: "Cancel one or more transfers.", @@ -268,7 +279,7 @@ export const en = { whoami: "Read broad account information through the put.io SDK.", }, root: { - chooseAuthSubcommand: "Choose `status`, `login`, `logout`, or `preview`.", + chooseAuthSubcommand: "Choose `status`, `login`, `logout`, `preview`, or `approve`.", help: "Use `putio describe` or `putio --help`.", }, transfers: { diff --git a/src/internal/command-specs.ts b/src/internal/command-specs.ts index 415e90e..a767db7 100644 --- a/src/internal/command-specs.ts +++ b/src/internal/command-specs.ts @@ -344,6 +344,19 @@ export const stringArgument = ( type: "string", }); +export const integerArgument = ( + name: string, + options: { + readonly description?: string; + readonly required?: boolean; + } = {}, +): CommandArgument => ({ + description: options.description, + name, + required: options.required ?? true, + type: "integer", +}); + export const repeatedStringFlag = ( name: string, options: { diff --git a/src/internal/metadata.test.ts b/src/internal/metadata.test.ts index 5a0cea3..d91a903 100644 --- a/src/internal/metadata.test.ts +++ b/src/internal/metadata.test.ts @@ -48,6 +48,7 @@ describe("describeCli", () => { "describe", "brand", "version", + "auth approve", "auth login", "auth status", "auth logout", @@ -59,6 +60,9 @@ describe("describeCli", () => { "download-links create", "download-links get", "events list", + "files start-from get", + "files start-from set", + "files start-from reset", "files list", "files search", "files mkdir", diff --git a/src/test-support/command-path-mocks.ts b/src/test-support/command-path-mocks.ts index c54d470..191787e 100644 --- a/src/test-support/command-path-mocks.ts +++ b/src/test-support/command-path-mocks.ts @@ -128,6 +128,15 @@ const createCommandPathMocks = () => { 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(() => @@ -175,6 +184,9 @@ const createCommandPathMocks = () => { 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(defaultAccountInfo())); const listEventsMock = vi.fn(() => Effect.succeed(defaultEventsResponse())); const createDownloadLinksMock = vi.fn(() => Effect.succeed({ id: 55 })); @@ -261,6 +273,7 @@ const createCommandPathMocks = () => { auth: { checkCodeMatch: checkCodeMatchMock, getCode: getCodeMock, + linkDevice: linkDeviceMock, }, downloadLinks: { create: createDownloadLinksMock, @@ -274,10 +287,13 @@ const createCommandPathMocks = () => { continueSearch: continueSearchFilesMock, createFolder: createFolderMock, delete: deleteFilesMock, + getStartFrom: getStartFromMock, list: listFilesMock, move: moveFilesMock, rename: renameFileMock, + resetStartFrom: resetStartFromMock, search: searchFilesMock, + setStartFrom: setStartFromMock, }, transfers: { addMany: addTransfersMock, @@ -308,15 +324,18 @@ const createCommandPathMocks = () => { getAuthStatusMock, checkCodeMatchMock, getCodeMock, + getStartFromMock, getTransferMock, listEventsMock, listFilesMock, listProfilesMock, listTransfersMock, + linkDeviceMock, moveFilesMock, openBrowserMock, provideSdkMock, renameFileMock, + resetStartFromMock, reannounceTransferMock, removeProfileMock, resolveAuthFlowConfigMock, @@ -324,6 +343,7 @@ const createCommandPathMocks = () => { retryTransferMock, savePersistedStateMock, searchFilesMock, + setStartFromMock, useProfileMock, waitForDeviceTokenMock, withAuthedSdkMock, @@ -351,6 +371,15 @@ export const resetCommandPathMocks = (mocks: ReturnType program); mocks.getCodeMock.mockImplementation(() => Effect.succeed({ code: "PUTIO1" })); mocks.checkCodeMatchMock.mockImplementation(() => Effect.succeed("token-123")); + mocks.linkDeviceMock.mockImplementation(() => + Effect.succeed({ + description: "Living room TV", + has_icon: false, + id: 77, + name: "put.io TV", + website: "https://put.io", + }), + ); mocks.continueTransfersMock.mockImplementation(() => Effect.succeed({ cursor: null, @@ -407,6 +436,9 @@ export const resetCommandPathMocks = (mocks: ReturnType Effect.succeed(90)); + mocks.setStartFromMock.mockImplementation(() => Effect.succeed({ status: "OK" })); + mocks.resetStartFromMock.mockImplementation(() => Effect.succeed({ status: "OK" })); mocks.getAccountInfoMock.mockImplementation(() => Effect.succeed(defaultAccountInfo())); mocks.listEventsMock.mockImplementation(() => Effect.succeed(defaultEventsResponse())); mocks.createDownloadLinksMock.mockImplementation(() => Effect.succeed({ id: 55 }));