Skip to content
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
24 changes: 21 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,12 @@ docker run --rm -it -v "$(pwd):/data" ghcr.io/not-three/cli crypto encrypt -f se
## Usage

```bash
not3 note save "hello world" # save a note (alias: not3 s)
not3 note save "hello world" # save a note; print share alternatives (alias: not3 s)
journalctl -u app | not3 s # pipe logs, get a share url on stdout
not3 note get <id> # fetch + decrypt (alias: not3 g)
not3 file upload video.mp4 # upload a file (alias: not3 u)
not3 file upload video.mp4 # upload a file; print share alternatives (alias: not3 u)
not3 file download <id> out.mp4 # download a file (alias: not3 d)
not3 p2p send video.mp4 # share a live transfer link and QR code
not3 p2p send video.mp4 # share a live transfer link and available alternatives
not3 p2p receive <link> [output] # receive; use --resume with a partial output
not3 crypto encrypt -f x.txt -o x.enc # local encryption
not3 config set server https://my.api # global defaults (~/.config/not3/config.json)
Expand All @@ -42,5 +42,23 @@ global config file managed by `not3 config`. Output adapts automatically:
pretty (colors, progress bars, QR codes) on a terminal, machine-readable when
piped — override with `--output-mode pretty|simple|stdout|raw`.

Each successful share (`not3 s`, `not3 u`, or `not3 p2p send`) prints its
available ways to open it in pretty and simple output. GCM notes and live P2P
shares omit commands that cannot decrypt them.
For example, a CBC note in simple mode shows:

```text
id: n1
seed: seedX
url: https://not-th.re/q/n1#az1zZWVkWA==
cli: not3 note get n1 --seed 'seedX'
docker: docker run --rm -it -v "$(pwd):/data" ghcr.io/not-three/cli note get n1 --seed 'seedX'
curl: curl https://raw.githubusercontent.com/not-three/main/refs/heads/main/scripts/decrypt-note.sh | bash -s https://api.not-th.re/note/n1/raw seedX
powershell: & ([scriptblock]::Create((irm https://raw.githubusercontent.com/not-three/main/refs/heads/main/scripts/decrypt-note.ps1))) 'https://api.not-th.re/note/n1/raw' 'seedX'
server: https://api.not-th.re/note/n1/decrypt?key=seedX
```

`stdout` still prints only the share URL, and `raw` still prints only the ID.

P2P transfer commands require Node.js 22 or newer. Reuse the printed seed with
`not3 p2p send <file> --seed <seed>` to restart an interrupted transfer.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
"test": "mocha"
},
"dependencies": {
"@not3/sdk": "^2.3.1",
"@not3/sdk": "2.4.0",
"@oclif/core": "^5.1.2",
"enquirer": "^2.4.1",
"node-datachannel": "^0.33.4",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
minimumReleaseAgeExclude:
- multer@2.2.0
- '@not3/sdk@2.3.1'
- '@not3/sdk@2.4.0'
overrides:
axios: 1.20.0
multer@>=1.0.0 <2.2.0: ^2.2.0
Expand Down
4 changes: 3 additions & 1 deletion src/commands/file/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import { fileShare } from '../../lib/share';
import { zipDirectory } from '../../lib/zip';

export default class FileUploadCommand extends BaseCommand {
static description = 'Encrypt and upload a file';
static description =
'Encrypt and upload a file; print available share alternatives';
static aliases = ['u'];
static args = {
input: Args.string({
Expand Down Expand Up @@ -96,6 +97,7 @@ export default class FileUploadCommand extends BaseCommand {
seedGenerated,
url: share.url,
curl: share.curl,
alternatives: share.alternatives,
});
} finally {
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
Expand Down
5 changes: 4 additions & 1 deletion src/commands/note/save.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { resolveTextInput } from '../../lib/input';
import { noteShare } from '../../lib/share';

export default class NoteSave extends BaseCommand {
static description = 'Encrypt and save a note on the server';
static description =
'Encrypt and save a note; print available share alternatives';
static aliases = ['s'];
static strict = false;
static args = {
Expand Down Expand Up @@ -55,6 +56,7 @@ export default class NoteSave extends BaseCommand {
id: note.id,
seed,
mode: s.mode,
fileName: flags.file,
});
reporter.share({
kind: 'note',
Expand All @@ -63,6 +65,7 @@ export default class NoteSave extends BaseCommand {
seedGenerated,
url: share.url,
curl: share.curl,
alternatives: share.alternatives,
});
}
}
16 changes: 9 additions & 7 deletions src/commands/p2p/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { p2pShare } from '../../lib/share';

export default class P2PSendCommand extends BaseCommand {
static description =
'Send a file directly to one receiver; reuse --seed to restart an interrupted transfer';
'Send a file directly to one receiver and print share alternatives; reuse --seed to restart an interrupted transfer';
static examples = [
'not3 p2p send ./video.mp4',
'not3 p2p send ./video.mp4 --seed <seed-from-previous-attempt>',
Expand Down Expand Up @@ -58,17 +58,19 @@ export default class P2PSendCommand extends BaseCommand {
sender.onProgress((p) => {
if (p.state === 'waiting-peer' && !shared) {
shared = true;
const share = p2pShare({
uiUrl: settings.uiUrl,
apiServer: settings.server,
id: sender.getSessionId(),
seed: sender.getSeed(),
});
reporter.share({
kind: 'p2p',
id: sender.getSessionId(),
seed: sender.getSeed(),
seedGenerated: !flags.seed,
url: p2pShare({
uiUrl: settings.uiUrl,
apiServer: settings.server,
id: sender.getSessionId(),
seed: sender.getSeed(),
}).url,
url: share.url,
alternatives: share.alternatives,
});
reporter.info(
'Waiting for a receiver to open the link… (Ctrl+C to cancel)',
Expand Down
24 changes: 21 additions & 3 deletions src/lib/reporter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import qrcode from 'qrcode-terminal';
import { ShareAlternative } from '@not3/sdk';
import { OutputModeSetting } from './config';

export type OutputMode = 'pretty' | 'simple' | 'stdout' | 'raw';
Expand All @@ -18,6 +19,7 @@ export interface ShareInfo {
seedGenerated: boolean;
url: string;
curl?: string;
alternatives: ShareAlternative[];
}

export interface Progress {
Expand Down Expand Up @@ -54,23 +56,39 @@ export class Reporter {
}

share(info: ShareInfo): void {
const rows = info.alternatives.filter((row) => row.id !== 'ui');
const labels: Record<string, string> = {
cli: 'CLI',
docker: 'Docker',
curl: 'cURL',
powershell: 'PS',
'server-decrypt': 'Server',
};
const keys: Record<string, string> = {
cli: 'cli',
docker: 'docker',
curl: 'curl',
powershell: 'powershell',
'server-decrypt': 'server',
};
switch (this.mode) {
case 'pretty': {
const width = Math.max(5, ...rows.map((row) => labels[row.id].length));
const kv = (label: string, value: string) =>
this.out.write(
` ${DIM}${label.padEnd(5)}${RESET} ${CYAN}${value}${RESET}\n`,
` ${DIM}${label.padEnd(width)}${RESET} ${CYAN}${value}${RESET}\n`,
);
this.out.write('\n' + renderQr(info.url) + '\n');
kv('ID', info.id);
kv('Seed', info.seed);
kv('URL', info.url);
if (info.curl) kv('cURL', info.curl);
for (const row of rows) kv(labels[row.id], row.value);
this.out.write('\n');
break;
}
case 'simple':
this.out.write(
`id: ${info.id}\nseed: ${info.seed}\nurl: ${info.url}\n${info.curl ? `curl: ${info.curl}\n` : ''}`,
`id: ${info.id}\nseed: ${info.seed}\nurl: ${info.url}\n${rows.map((row) => `${keys[row.id]}: ${row.value}\n`).join('')}`,
);
break;
case 'stdout':
Expand Down
53 changes: 40 additions & 13 deletions src/lib/share.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
import { FragmentData, ShareGenerator } from '@not3/sdk';
import { FragmentData, ShareAlternative, ShareGenerator } from '@not3/sdk';
import { CryptoMode, DEFAULTS, normalizeServerUrl } from './config';

interface ShareResult {
url: string;
alternatives: ShareAlternative[];
curl?: string;
}

function shareResult(alternatives: ShareAlternative[]): ShareResult {
return {
url: alternatives[0].value,
alternatives,
curl: alternatives.find((alternative) => alternative.id === 'curl')?.value,
};
}

function generator(
uiUrl: string,
apiServer: string,
Expand All @@ -21,17 +35,24 @@ export function noteShare(opts: {
id: string;
seed: string;
mode: CryptoMode;
}): { url: string; curl: string } {
fileName?: string;
}): ShareResult {
const { gen, isDefault, server } = generator(opts.uiUrl, opts.apiServer);
const fragment = new FragmentData({
seed: opts.seed,
server: isDefault ? null : server + '/',
cryptoMode: opts.mode,
});
return {
url: gen.noteUi(opts.id, fragment),
curl: gen.noteCurl(opts.id, opts.seed),
};
return shareResult(
gen.alternatives({
kind: 'note',
id: opts.id,
seed: opts.seed,
cryptoMode: opts.mode,
fileName: opts.fileName,
fragment,
}),
);
}

export function fileShare(opts: {
Expand All @@ -40,20 +61,26 @@ export function fileShare(opts: {
id: string;
seed: string;
fileName: string;
}): { url: string; curl: string } {
}): ShareResult {
const { gen } = generator(opts.uiUrl, opts.apiServer);
return {
url: gen.fileUi(opts.id, opts.seed),
curl: gen.fileCurl(opts.id, opts.seed, opts.fileName),
};
return shareResult(
gen.alternatives({
kind: 'file',
id: opts.id,
seed: opts.seed,
fileName: opts.fileName,
}),
);
}

export function p2pShare(opts: {
uiUrl: string;
apiServer: string;
id: string;
seed: string;
}): { url: string } {
}): ShareResult {
const { gen } = generator(opts.uiUrl, opts.apiServer);
return { url: gen.p2pUi(opts.id, opts.seed) };
return shareResult(
gen.alternatives({ kind: 'p2p', id: opts.id, seed: opts.seed }),
);
}
38 changes: 37 additions & 1 deletion test/commands/file.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { runCommand } from '@oclif/test';
import { expect } from 'chai';
import { mkdirSync, mkdtempSync, readdirSync, writeFileSync } from 'fs';
import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import nock from 'nock';
Expand All @@ -26,6 +26,42 @@ const INFO = {
describe('not3 file upload --dir', () => {
afterEach(() => nock.cleanAll());

it('prints file alternatives after a successful upload', async () => {
const base = mkdtempSync(join(tmpdir(), 'not3-share-file-'));
const file = join(base, 'sample.txt');
writeFileSync(file, 'hi');
const seed = Crypto.generateSeed();
try {
nock(SERVER).persist().get('/info').reply(200, INFO);
nock(SERVER).post('/file/upload').reply(201, { id: 'file-share' });
nock(SERVER)
.get('/file/upload/file-share')
.query(true)
.reply(200, { url: `${SERVER}/chunk/share` });
nock(SERVER).put('/chunk/share').reply(200, '', { ETag: '"etag-share"' });
nock(SERVER).put('/file/upload/file-share').reply(200);
const { stdout, error } = await runCommand([
'u',
file,
'-s',
SERVER,
'--seed',
seed,
'--output-mode',
'simple',
]);
expect(error).to.equal(undefined);
expect(stdout).to.contain(
`cli: not3 file download file-share 'sample.txt' --seed '${seed}' --server ${SERVER}\n`,
);
expect(stdout).to.contain('docker: docker run --rm -it');
expect(stdout).to.contain('curl: curl ');
expect(stdout).to.contain('powershell: & ([scriptblock]::Create');
} finally {
rmSync(base, { recursive: true, force: true });
}
});

it('rejects --dir when the input is not a directory', async () => {
const base = mkdtempSync(join(tmpdir(), 'not3-test-'));
const file = join(base, 'plain.txt');
Expand Down
Loading
Loading