From 5470d1ef3fc1a79e7a0a08aff89ac4581695aa10 Mon Sep 17 00:00:00 2001 From: Corentin Thomasset Date: Wed, 27 Nov 2024 13:55:23 +0100 Subject: [PATCH] feat(cli): support uploading files --- .github/workflows/ci-cli.yaml | 2 +- packages/cli/README.md | 14 +++- .../src/create-note/create-note.command.ts | 49 +++++++++++-- .../create-note/create-note.usecases.test.ts | 23 ++++++ .../src/create-note/create-note.usecases.ts | 18 +++++ packages/cli/src/files/files.services.test.ts | 72 +++++++++++++++++++ packages/cli/src/files/files.services.ts | 39 ++++++++++ packages/docs/src/integrations/cli.md | 13 +++- 8 files changed, 221 insertions(+), 9 deletions(-) create mode 100644 packages/cli/src/create-note/create-note.usecases.test.ts create mode 100644 packages/cli/src/create-note/create-note.usecases.ts create mode 100644 packages/cli/src/files/files.services.test.ts create mode 100644 packages/cli/src/files/files.services.ts diff --git a/.github/workflows/ci-cli.yaml b/.github/workflows/ci-cli.yaml index 3b61f0e8..ae58d6ec 100644 --- a/.github/workflows/ci-cli.yaml +++ b/.github/workflows/ci-cli.yaml @@ -1,4 +1,4 @@ -name: CI - Lib +name: CI - Cli on: pull_request: diff --git a/packages/cli/README.md b/packages/cli/README.md index 64984d6f..ddf0c2e4 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -39,10 +39,20 @@ enclosed [options] enclosed create "Hello, World!" # Using stdin -cat file.txt | enclosed create +cat file.txt | enclosed create --stdin +# or +cat file.txt | enclosed create -s + +# To add files as attachments +enclosed create --file file1.txt --file file2.txt "Hello, World!" # With full options -enclosed create --deleteAfterReading --password "password" --ttl 3600 "Hello, World!" +enclosed create --file file1.txt --deleteAfterReading --password "password" --ttl 3600 "Hello, World!" + +# Get more information about the command +enclosed create --help +# or +enclosed create -h ``` ### View a note diff --git a/packages/cli/src/create-note/create-note.command.ts b/packages/cli/src/create-note/create-note.command.ts index 1474d6bc..1d2a78b4 100644 --- a/packages/cli/src/create-note/create-note.command.ts +++ b/packages/cli/src/create-note/create-note.command.ts @@ -1,10 +1,12 @@ import { createNote } from '@enclosed/lib'; import { defineCommand, showUsage } from 'citty'; +import { castArray } from 'lodash-es'; import ora from 'ora'; import pc from 'picocolors'; import { getInstanceUrl } from '../config/config.usecases'; -import { readFromStdin } from '../shared/cli.models'; +import { buildFileAssets, checkFilesExist } from '../files/files.services'; import { looksLikeRateLimitError } from '../shared/http.models'; +import { getNoteContent } from './create-note.usecases'; const ONE_HOUR_IN_SECONDS = 60 * 60; @@ -39,26 +41,63 @@ export const createNoteCommand = defineCommand({ type: 'boolean', default: false, }, + file: { + description: 'Files to attach to the note', + type: 'string', + alias: 'f', + required: false, + }, + stdin: { + description: 'Read note content from stdin', + alias: 's', + type: 'boolean', + default: false, + }, }, run: async ({ args }) => { - const { password, content: rawContent, deleteAfterReading, ttl: ttlInSeconds } = args; + const { + password, + content: rawContent, + deleteAfterReading, + ttl: ttlInSeconds, + file, + stdin: shouldReadFromStdin, + } = args; + + const filePaths = file ? castArray(file) : []; - const content = rawContent ?? await readFromStdin(); + const content = await getNoteContent({ + rawContent, + shouldReadFromStdin, + }); - if (!content) { + if (!content && !filePaths.length) { await showUsage(createNoteCommand); return; } + if (filePaths.length) { + const { missingFiles, allFilesExist } = await checkFilesExist({ filePaths }); + + if (!allFilesExist) { + console.error(pc.red('The following files do not exist or are not accessible:')); + console.error(pc.red(missingFiles.map(l => ` - ${l}`).join('\n'))); + return; + } + } + const spinner = ora('Creating note').start(); try { + const { assets } = await buildFileAssets({ filePaths }); + const { noteUrl } = await createNote({ - content: String(content), + content: content ?? '', password, deleteAfterReading, ttlInSeconds: Number(ttlInSeconds), clientBaseUrl: getInstanceUrl(), + assets, }); spinner.succeed('Note created successfully'); diff --git a/packages/cli/src/create-note/create-note.usecases.test.ts b/packages/cli/src/create-note/create-note.usecases.test.ts new file mode 100644 index 00000000..070108d1 --- /dev/null +++ b/packages/cli/src/create-note/create-note.usecases.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'vitest'; +import { getNoteContent } from './create-note.usecases'; + +describe('create-note usecases', () => { + describe('getNoteContent', () => { + test('by default, returns the raw content', async () => { + expect(await getNoteContent({ rawContent: 'content' })).toEqual('content'); + }); + + test('when the user wants to read from stdin, reads from stdin, regardless of the content', async () => { + const readFromStdin = async () => 'stdin content'; + expect(await getNoteContent({ rawContent: 'content', shouldReadFromStdin: true, readFromStdin })).toEqual('stdin content'); + }); + + // Citty, the cli builder does not support single dash as positional argument, so this feature is not supported + // test('to follow the Unix convention, reads from stdin when the raw content is "-"', async () => { + // const readFromStdin = async () => 'stdin content'; + // expect(await getNoteContent({ rawContent: '-', readFromStdin })).toEqual('stdin content'); + // expect(await getNoteContent({ rawContent: '-', readFromStdin, shouldReadFromStdin: true })).toEqual('stdin content'); + // expect(await getNoteContent({ rawContent: '-', readFromStdin, shouldReadFromStdin: false })).toEqual('stdin content'); + // }); + }); +}); diff --git a/packages/cli/src/create-note/create-note.usecases.ts b/packages/cli/src/create-note/create-note.usecases.ts new file mode 100644 index 00000000..02ea3273 --- /dev/null +++ b/packages/cli/src/create-note/create-note.usecases.ts @@ -0,0 +1,18 @@ +import { readFromStdin as readFromStdinImpl } from '../shared/cli.models'; + +export async function getNoteContent({ + rawContent, + shouldReadFromStdin, + readFromStdin = readFromStdinImpl, +}: { + rawContent: string | undefined; + shouldReadFromStdin?: boolean; + readFromStdin?: () => Promise; +}) { + if (shouldReadFromStdin) { + const content = await readFromStdin(); + return content; + } + + return rawContent; +} diff --git a/packages/cli/src/files/files.services.test.ts b/packages/cli/src/files/files.services.test.ts new file mode 100644 index 00000000..d7a015ca --- /dev/null +++ b/packages/cli/src/files/files.services.test.ts @@ -0,0 +1,72 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; +import { checkFileExist, checkFilesExist } from './files.services'; + +async function createTempDir() { + const ostmpdir = os.tmpdir(); + const tmpdir = path.join(ostmpdir, 'unit-test-'); + return await fs.mkdtemp(tmpdir); +} + +describe('files services', () => { + let tmpDir: string = ''; + let fileAPath: string = ''; + let fileBPath: string = ''; + let nonExistentFilePath: string = ''; + + beforeAll(async () => { + tmpDir = await createTempDir(); + fileAPath = path.join(tmpDir, 'file-a.txt'); + fileBPath = path.join(tmpDir, 'file-b.txt'); + nonExistentFilePath = path.join(tmpDir, 'non-existent-file.txt'); + + await fs.writeFile(fileAPath, 'file a content'); + await fs.writeFile(fileBPath, 'file b content'); + }); + + afterAll(async () => { + await fs.rm(tmpDir, { recursive: true }); + }); + + describe('checkFileExist', () => { + test('checks if a file exists', async () => { + expect(await checkFileExist({ filePath: fileAPath })).to.eql(true); + expect(await checkFileExist({ filePath: nonExistentFilePath })).to.eql(false); + }); + + test('a directory is not a file', async () => { + expect(await checkFileExist({ filePath: tmpDir })).to.eql(false); + }); + }); + + describe('checkFilesExist', () => { + test('ensures all provided files exist', async () => { + expect( + await checkFilesExist({ filePaths: [fileAPath, fileBPath] }), + ).to.eql({ + missingFiles: [], + allFilesExist: true, + }); + }); + + test('the missing files are reported', async () => { + expect( + await checkFilesExist({ filePaths: [fileAPath, fileBPath, nonExistentFilePath, tmpDir] }), + ).to.eql({ + missingFiles: [nonExistentFilePath, tmpDir], + allFilesExist: false, + }); + }); + + test('when no files are provided, they are all considered to exist', async () => { + expect( + await checkFilesExist({ filePaths: [] }), + ).to.eql({ + missingFiles: [], + allFilesExist: true, + }); + }); + }); +}); diff --git a/packages/cli/src/files/files.services.ts b/packages/cli/src/files/files.services.ts new file mode 100644 index 00000000..d403136d --- /dev/null +++ b/packages/cli/src/files/files.services.ts @@ -0,0 +1,39 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileToNoteAsset } from '@enclosed/lib'; + +export async function checkFileExist({ filePath }: { filePath: string }): Promise { + try { + const stats = await fs.stat(filePath); + return stats.isFile(); + } catch { + return false; + } +} + +export async function checkFilesExist({ filePaths }: { filePaths: string[] }) { + const missingFiles = ( + await Promise.all( + filePaths.map(async (filePath) => { + const exists = await checkFileExist({ filePath }); + return exists ? null : filePath; + }), + )).filter(Boolean) as string[]; + + return { missingFiles, allFilesExist: missingFiles.length === 0 }; +} + +export async function buildFileAssets({ filePaths }: { filePaths: string[] }) { + const assets = await Promise.all( + filePaths.map(async (filePath) => { + const content = await fs.readFile(filePath); + const file = new File([content], path.basename(filePath)); + + return fileToNoteAsset({ + file, + }); + }), + ); + + return { assets }; +} diff --git a/packages/docs/src/integrations/cli.md b/packages/docs/src/integrations/cli.md index 1879c87c..ef8571a8 100644 --- a/packages/docs/src/integrations/cli.md +++ b/packages/docs/src/integrations/cli.md @@ -45,11 +45,21 @@ This command will return a URL that you can share with the intended recipient. You can also create a note from the contents of a file or the output of another command: ```bash -cat file.txt | enclosed create +cat file.txt | enclosed create --stdin +# or +cat file.txt | enclosed create -s ``` This is useful for sending longer text or data generated by other commands. +### Adding Attachments + +You can attach files to a note by providing the `--file` option: + +```bash +enclosed create --file file1.txt --file file2.txt "Hello, World!" +``` + ### With Full Options The CLI allows you to set additional security options such as password protection, self-destruction after reading, and time-to-live (TTL) for the note: @@ -57,6 +67,7 @@ The CLI allows you to set additional security options such as password protectio ```bash enclosed create --deleteAfterReading --password "mypassword" --ttl 3600 "This is a secure message." ``` + ### Options Summary - `--deleteAfterReading`: Enable self-destruction after the note is read.