Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cli): support uploading files #359

Merged
merged 1 commit into from
Dec 5, 2024
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
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
Loading