diff --git a/README.md b/README.md index 21505e8..52fbddf 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/skills/putio-cli/references/guardrails.md b/skills/putio-cli/references/guardrails.md index f8c8ea6..5bb10ce 100644 --- a/skills/putio-cli/references/guardrails.md +++ b/skills/putio-cli/references/guardrails.md @@ -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: diff --git a/skills/putio-cli/references/writes.md b/skills/putio-cli/references/writes.md index 65e6698..da3a1ce 100644 --- a/skills/putio-cli/references/writes.md +++ b/skills/putio-cli/references/writes.md @@ -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 ``` @@ -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 ``` @@ -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. diff --git a/src/cli.test.ts b/src/cli.test.ts index 8d7c14d..d29b366 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -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"); @@ -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 }), ]); diff --git a/src/command-paths.test.ts b/src/command-paths.test.ts index 64a0a59..d9795ff 100644 --- a/src/command-paths.test.ts +++ b/src/command-paths.test.ts @@ -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(); + +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; @@ -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" })); @@ -296,6 +322,7 @@ const mocks = vi.hoisted(() => { rename: renameFileMock, resetStartFrom: resetStartFromMock, search: searchFilesMock, + upload: uploadFileMock, setStartFrom: setStartFromMock, }, transfers: { @@ -348,6 +375,7 @@ const mocks = vi.hoisted(() => { searchFilesMock, setStartFromMock, useProfileMock, + uploadFileMock, waitForDeviceTokenMock, withAuthedSdkMock, withTerminalLoaderMock, @@ -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([ diff --git a/src/commands/files.test.ts b/src/commands/files.test.ts index 92e283f..2f57a18 100644 --- a/src/commands/files.test.ts +++ b/src/commands/files.test.ts @@ -5,6 +5,7 @@ import { renderFilesTerminal } from "../internal/terminal/files-terminal.js"; import { renderFileCreatedTerminal, renderFileRenamedTerminal, + renderFileUploadedTerminal, renderFilesDeletedTerminal, renderFilesMovedTerminal, } from "./files.js"; @@ -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({ diff --git a/src/commands/files.ts b/src/commands/files.ts index 4968773..92e2375 100644 --- a/src/commands/files.ts +++ b/src/commands/files.ts @@ -16,6 +16,7 @@ import { pageAllOption, resolveMutationInput, resolveReadOutputControls, + validateLocalPathInput, validateNameLikeInput, withAuthedSdk, writeDryRunPlan, @@ -33,6 +34,7 @@ import { type CommandSpec, } from "../internal/command-specs.js"; import { translate } from "../i18n/index.js"; +import { inspectLocalUpload, openLocalUploadBlob } from "../internal/local-upload.js"; import { withTerminalLoader } from "../internal/loader-service.js"; import { writeOutput } from "../internal/output-service.js"; import { renderFilesTerminal } from "../internal/terminal/files-terminal.js"; @@ -72,6 +74,8 @@ const fileSortChoices = [ const sortByConfig = defineChoiceOption("sort-by", fileSortChoices, { optional: true }); const optionalFileIdConfig = defineIntegerOption("id", { optional: true }); const optionalFileNameConfig = defineTextOption("name", { optional: true }); +const uploadPathConfig = defineTextOption("path", { optional: true }); +const uploadFileNameConfig = defineTextOption("file-name", { optional: true }); const parentIdOption = parentIdConfig.option; const perPageOption = perPageConfig.option; @@ -84,6 +88,8 @@ const fileTypeOption = fileTypeConfig.option; const sortByOption = sortByConfig.option; const optionalFileIdOption = optionalFileIdConfig.option; const optionalFileNameOption = optionalFileNameConfig.option; +const uploadPathOption = uploadPathConfig.option; +const uploadFileNameOption = uploadFileNameConfig.option; const startFromFileIdArgument = Argument.integer("file-id"); const optionalStartFromFileIdArgument = startFromFileIdArgument.pipe(Argument.optional); const optionalStartFromTimeArgument = Argument.integer("seconds").pipe(Argument.optional); @@ -118,6 +124,12 @@ const FilesMoveInputSchema = Schema.Struct({ parent_id: Schema.Number, }); +const FilesUploadInputSchema = Schema.Struct({ + file_name: Schema.optional(NonBlankStringSchema), + parent_id: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), + path: NonBlankStringSchema, +}); + const FilesStartFromSetInputSchema = Schema.Struct({ file_id: PositiveIntegerSchema, time: NonNegativeIntegerSchema, @@ -151,6 +163,14 @@ const requiredIds = (value: ReadonlyArray, message: string) => { return value; }; +const optionalNonNegativeInteger = (value: number | undefined, message: string) => { + if (value !== undefined && (!Number.isInteger(value) || value < 0)) { + throw new CliCommandInputError({ message }); + } + + return value; +}; + const requiredPositiveInteger = (value: number | undefined, message: string) => { if (value === undefined || !Number.isInteger(value) || value <= 0) { throw new CliCommandInputError({ message }); @@ -167,6 +187,24 @@ const requiredNonNegativeInteger = (value: number | undefined, message: string) return value; }; +export const renderFileUploadedTerminal = ( + value: + | { readonly type: "file"; readonly file: { readonly id: number; readonly name: string } } + | { + readonly type: "transfer"; + readonly transfer: { readonly id: number; readonly name: string }; + }, +) => + value.type === "file" + ? translate("cli.files.terminal.uploadedFile", { + id: value.file.id, + name: value.file.name, + }) + : translate("cli.files.terminal.uploadedTransfer", { + id: value.transfer.id, + name: value.transfer.name, + }); + export const renderFileCreatedTerminal = (value: { readonly id: number; readonly name: string; @@ -320,6 +358,84 @@ const filesMkdir = Command.make( }), ); +const filesUpload = Command.make( + "upload", + { + dryRun: dryRunOption, + fileName: uploadFileNameOption, + json: jsonOption, + output: outputOption, + parentId: parentIdOption, + path: uploadPathOption, + }, + ({ dryRun, fileName, json, output, parentId, path }) => + Effect.gen(function* () { + const input = yield* resolveMutationInput({ + buildFromFlags: () => ({ + file_name: getOption(fileName), + parent_id: getOption(parentId), + path: requiredNonEmptyText( + getOption(path), + "Provide `--path` or `--json` for `files upload`.", + ), + }), + json, + schema: FilesUploadInputSchema, + }).pipe( + Effect.map((value) => ({ + ...value, + file_name: + value.file_name === undefined + ? undefined + : validateNameLikeInput( + "`files upload --file-name`", + requiredNonEmptyText( + value.file_name, + "Expected `files upload --file-name` to be a non-empty string.", + ), + ), + parent_id: optionalNonNegativeInteger( + value.parent_id, + "Expected `files upload --parent-id` to be a non-negative integer.", + ), + path: validateLocalPathInput("`files upload --path`", value.path), + })), + ); + const prepared = yield* inspectLocalUpload(input.path); + const resolvedFileName = validateNameLikeInput( + "`files upload --file-name`", + input.file_name ?? prepared.fileName, + ); + const plan = { + file_name: resolvedFileName, + parent_id: input.parent_id, + path: input.path, + size: prepared.size, + }; + + if (dryRun) { + return yield* writeDryRunPlan("files upload", plan, getOption(output)); + } + + const file = yield* openLocalUploadBlob(prepared.path); + const result = yield* withTerminalLoader( + { + message: translate("cli.files.command.uploading", { name: resolvedFileName }), + output: getOption(output), + }, + withAuthedSdk(({ sdk }) => + sdk.files.upload({ + file, + fileName: resolvedFileName, + parentId: input.parent_id, + }), + ), + ); + + yield* writeOutput(result, getOption(output), renderFileUploadedTerminal); + }), +); + const filesRename = Command.make( "rename", { @@ -652,6 +768,7 @@ export const filesCommand = Command.make("files", {}, () => Effect.void).pipe( filesList, filesSearchCommand, filesMkdir, + filesUpload, filesRename, filesMove, filesDelete, @@ -784,6 +901,32 @@ export const filesCommandSpecs = [ kind: "write", purpose: translate("cli.metadata.filesMkdir"), }, + { + auth: { required: true }, + capabilities: { + dryRun: true, + fieldSelection: false, + rawJsonInput: true, + streaming: false, + }, + command: "files upload", + input: { + flags: [ + dryRunFlag(), + uploadFileNameConfig.flag, + jsonFlag(), + outputFlag(), + parentIdConfig.flag, + uploadPathConfig.flag, + ], + json: jsonShapeFromSchema(FilesUploadInputSchema, [ + "`path` must resolve to a readable regular file.", + "`file_name` rejects control characters and path traversal segments like `../` or `%2e`.", + ]), + }, + kind: "write", + purpose: translate("cli.metadata.filesUpload"), + }, { auth: { required: true }, capabilities: { diff --git a/src/i18n/catalog/en.ts b/src/i18n/catalog/en.ts index 386bfa6..64f087e 100644 --- a/src/i18n/catalog/en.ts +++ b/src/i18n/catalog/en.ts @@ -223,6 +223,7 @@ export const en = { moving: "Moving {{count}} file(s) to parent {{parentId}}...", renaming: 'Renaming file {{id}} to "{{name}}"...', searching: 'Searching files for "{{query}}"...', + uploading: 'Uploading "{{name}}"...', }, terminal: { created: 'created folder "{{name}}" (id {{id}}, parent {{parentId}})', @@ -241,6 +242,8 @@ export const en = { summary: "Showing {{count}} file(s){{totalSuffix}}.", summaryInParent: "Showing {{count}} file(s) in {{name}}{{totalSuffix}}.", totalSuffix: " ({{total}} total)", + uploadedFile: 'uploaded file "{{name}}" (id {{id}})', + uploadedTransfer: 'queued upload transfer "{{name}}" (id {{id}})', }, }, metadata: { @@ -264,6 +267,7 @@ 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.", + filesUpload: "Upload a readable local file into put.io.", 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.", diff --git a/src/internal/command.ts b/src/internal/command.ts index c719c9c..13a4664 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -288,6 +288,14 @@ export const validateNameLikeInput = (label: string, value: string) => value, }); +export const validateLocalPathInput = (label: string, value: string) => + validateSafeString({ + allowPathTraversal: true, + allowQueryOrFragment: true, + label, + value, + }); + const parseRequestedFields = (raw: string) => { const parts = raw.split(",").map((part) => part.trim()); diff --git a/src/internal/local-upload.ts b/src/internal/local-upload.ts new file mode 100644 index 0000000..e84ed65 --- /dev/null +++ b/src/internal/local-upload.ts @@ -0,0 +1,44 @@ +import { openAsBlob } from "node:fs"; + +import { Effect, FileSystem, Path } from "effect"; + +import { CliCommandInputError } from "./command.js"; + +const unreadableUploadFile = () => + new CliCommandInputError({ + message: "Unable to read the local upload file. Verify that the path exists and is readable.", + }); + +export const inspectLocalUpload = (inputPath: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resolvedPath = yield* fileSystem + .realPath(inputPath) + .pipe(Effect.mapError(unreadableUploadFile)); + const info = yield* fileSystem.stat(resolvedPath).pipe(Effect.mapError(unreadableUploadFile)); + + if (info.type !== "File") { + return yield* Effect.fail( + new CliCommandInputError({ + message: "Expected the upload path to point to a regular file.", + }), + ); + } + + yield* fileSystem + .access(resolvedPath, { readable: true }) + .pipe(Effect.mapError(unreadableUploadFile)); + + return { + fileName: path.basename(resolvedPath), + path: resolvedPath, + size: Number(info.size), + }; + }); + +export const openLocalUploadBlob = (path: string) => + Effect.tryPromise({ + try: () => openAsBlob(path), + catch: unreadableUploadFile, + }); diff --git a/src/internal/metadata.test.ts b/src/internal/metadata.test.ts index d91a903..3f06e11 100644 --- a/src/internal/metadata.test.ts +++ b/src/internal/metadata.test.ts @@ -66,6 +66,7 @@ describe("describeCli", () => { "files list", "files search", "files mkdir", + "files upload", "files rename", "files move", "files delete", diff --git a/src/test-support/command-path-mocks.ts b/src/test-support/command-path-mocks.ts index 191787e..01603a0 100644 --- a/src/test-support/command-path-mocks.ts +++ b/src/test-support/command-path-mocks.ts @@ -184,6 +184,12 @@ 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 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" })); @@ -293,6 +299,7 @@ const createCommandPathMocks = () => { rename: renameFileMock, resetStartFrom: resetStartFromMock, search: searchFilesMock, + upload: uploadFileMock, setStartFrom: setStartFromMock, }, transfers: { @@ -345,6 +352,7 @@ const createCommandPathMocks = () => { searchFilesMock, setStartFromMock, useProfileMock, + uploadFileMock, waitForDeviceTokenMock, withAuthedSdkMock, withTerminalLoaderMock, @@ -436,6 +444,12 @@ export const resetCommandPathMocks = (mocks: ReturnType + Effect.succeed({ + file: { id: 88, name: "movie.mp4" }, + type: "file" as const, + }), + ); mocks.getStartFromMock.mockImplementation(() => Effect.succeed(90)); mocks.setStartFromMock.mockImplementation(() => Effect.succeed({ status: "OK" })); mocks.resetStartFromMock.mockImplementation(() => Effect.succeed({ status: "OK" }));