Skip to content

Commit

Permalink
feat(cli): support uploading files (#359)
Browse files Browse the repository at this point in the history
  • Loading branch information
CorentinTh authored Dec 5, 2024
1 parent b051f55 commit e4621c3
Show file tree
Hide file tree
Showing 8 changed files with 221 additions and 9 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci-cli.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: CI - Lib
name: CI - Cli

on:
pull_request:
Expand Down
14 changes: 12 additions & 2 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,20 @@ enclosed <command> [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
Expand Down
49 changes: 44 additions & 5 deletions packages/cli/src/create-note/create-note.command.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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');
Expand Down
23 changes: 23 additions & 0 deletions packages/cli/src/create-note/create-note.usecases.test.ts
Original file line number Diff line number Diff line change
@@ -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');
// });
});
});
18 changes: 18 additions & 0 deletions packages/cli/src/create-note/create-note.usecases.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
}) {
if (shouldReadFromStdin) {
const content = await readFromStdin();
return content;
}

return rawContent;
}
72 changes: 72 additions & 0 deletions packages/cli/src/files/files.services.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
});
39 changes: 39 additions & 0 deletions packages/cli/src/files/files.services.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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 };
}
13 changes: 12 additions & 1 deletion packages/docs/src/integrations/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,29 @@ 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:

```bash
enclosed create --deleteAfterReading --password "mypassword" --ttl 3600 "This is a secure message."
```

### Options Summary

- `--deleteAfterReading`: Enable self-destruction after the note is read.
Expand Down

0 comments on commit e4621c3

Please sign in to comment.