From 18f14849242df88ddffcfd1b085447aa9b486448 Mon Sep 17 00:00:00 2001 From: OMO - AI Orchestrator Date: Wed, 7 Oct 2026 00:31:21 +0200 Subject: [PATCH 1/2] feat(share): consume released SDK alternatives catalog --- package.json | 2 +- pnpm-lock.yaml | 10 ++-- pnpm-workspace.yaml | 2 +- src/lib/share.ts | 53 ++++++++++++++----- test/lib/share.test.ts | 114 ++++++++++++++++++++++++++++++++++++++++- 5 files changed, 160 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index fd96b5f..c9cbb08 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73dd821..92c4f09 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,8 +14,8 @@ importers: .: dependencies: '@not3/sdk': - specifier: ^2.3.1 - version: 2.3.1 + specifier: 2.4.0 + version: 2.4.0 '@oclif/core': specifier: ^5.1.2 version: 5.1.2 @@ -258,8 +258,8 @@ packages: cpu: [x64] os: [win32] - '@not3/sdk@2.3.1': - resolution: {integrity: sha512-Q32BxPPpy6CsG/gtRAaqG2Sj36ATAhp0wjfbAudMHbhnTLbd0DHqTvwHNeP23RAnMvZTh3KrUCEHu82rLMLJsA==} + '@not3/sdk@2.4.0': + resolution: {integrity: sha512-bpr0Qpf6ATlJiY7SQcE9wKZPJQZzooY2xGlAfQyuClgX8wnF2I6m84P4CtMVjiQ9aCqD9vxWalB19xN5zqLgRw==} '@npmcli/agent@5.0.2': resolution: {integrity: sha512-EkzGmEsgbQ1rqWkRJe2P0oQHx/ylZozDUNPMXCklLuSFL3GY+QyEfBUjhjCsgGXzh4OGpnHvkboSQgczjP/jJg==} @@ -1705,7 +1705,7 @@ snapshots: '@node-datachannel/win32-x64-msvc@0.33.4': optional: true - '@not3/sdk@2.3.1': + '@not3/sdk@2.4.0': dependencies: axios: 1.20.0 semver: 7.8.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0258d85..05030ba 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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 diff --git a/src/lib/share.ts b/src/lib/share.ts index 2781bf4..498965c 100644 --- a/src/lib/share.ts +++ b/src/lib/share.ts @@ -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, @@ -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: { @@ -40,12 +61,16 @@ 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: { @@ -53,7 +78,9 @@ export function p2pShare(opts: { 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 }), + ); } diff --git a/test/lib/share.test.ts b/test/lib/share.test.ts index 4311390..4022b44 100644 --- a/test/lib/share.test.ts +++ b/test/lib/share.test.ts @@ -1,8 +1,77 @@ import { expect } from 'chai'; -import { FragmentData } from '@not3/sdk'; +import { FragmentData, ShareGenerator } from '@not3/sdk'; import { fileShare, noteShare, p2pShare } from '../../src/lib/share'; describe('noteShare', () => { + it('returns the released catalog in order for a CBC note', () => { + const result = noteShare({ + uiUrl: 'https://not-th.re/', + apiServer: 'https://api.not-th.re', + id: 'n1', + seed: 'seedX', + mode: 'cbc', + }); + const expected = new ShareGenerator({ + uiUrl: 'https://not-th.re/', + apiUrl: 'https://api.not-th.re/', + storeServer: false, + }).alternatives({ + kind: 'note', + id: 'n1', + seed: 'seedX', + cryptoMode: 'cbc', + fragment: new FragmentData({ + seed: 'seedX', + server: null, + cryptoMode: 'cbc', + }), + }); + expect(result.alternatives).to.deep.equal(expected); + expect(result.alternatives.map((row) => row.id)).to.deep.equal([ + 'ui', + 'cli', + 'docker', + 'curl', + 'powershell', + 'server-decrypt', + ]); + expect(result.url).to.equal(result.alternatives[0].value); + expect(result.curl).to.equal(result.alternatives[3].value); + }); + + it('passes GCM mode and omits shell decryption alternatives', () => { + const result = noteShare({ + uiUrl: 'https://not-th.re/', + apiServer: 'https://api.not-th.re', + id: 'n1', + seed: 'seedX', + mode: 'gcm', + }); + expect(result.alternatives.map((row) => row.id)).to.deep.equal([ + 'ui', + 'cli', + 'docker', + ]); + expect(result.alternatives[1].value).to.equal( + "not3 note get n1 --seed 'seedX' --mode gcm", + ); + }); + + it('uses save commands for notes read from a file', () => { + const result = noteShare({ + uiUrl: 'https://not-th.re/', + apiServer: 'https://api.not-th.re', + id: 'n1', + seed: 'seedX', + mode: 'cbc', + fileName: 'note.txt', + }); + expect(result.alternatives[1].value).to.equal( + "not3 note get n1 --seed 'seedX' --output 'note.txt'", + ); + expect(result.alternatives[3].value).to.contain(' > note.txt'); + expect(result.alternatives[4].value).to.contain(" > 'note.txt'"); + }); it('builds a ui url containing the note id and seed fragment', () => { const { url, curl } = noteShare({ uiUrl: 'https://not-th.re/', @@ -34,6 +103,13 @@ describe('noteShare', () => { 'https://other.example/', ); expect(FragmentData.fromURL(def.url).server).to.equal(null); + expect(custom.alternatives[1].value).to.contain( + '--server https://other.example', + ); + expect(custom.alternatives[2].value).to.contain( + '--server https://other.example', + ); + expect(custom.alternatives[0].value).to.equal(custom.url); }); it('normalizes a trailing-slash apiServer to avoid double slashes', () => { const { url } = noteShare({ @@ -49,6 +125,22 @@ describe('noteShare', () => { }); describe('p2pShare', () => { + it('returns only link, CLI, and Docker alternatives', () => { + const result = p2pShare({ + uiUrl: 'https://not-th.re/', + apiServer: 'https://api.not-th.re', + id: 's1', + seed: 'seedX', + }); + expect(result.alternatives.map((row) => row.id)).to.deep.equal([ + 'ui', + 'cli', + 'docker', + ]); + expect(result.alternatives[1].value).to.equal( + `not3 p2p receive '${result.url}'`, + ); + }); it('puts the seed and custom server in the fragment', () => { const url = p2pShare({ uiUrl: 'https://not-th.re/', @@ -65,6 +157,26 @@ describe('p2pShare', () => { }); describe('fileShare', () => { + it('uses the released file catalog and preserves the filename', () => { + const result = fileShare({ + uiUrl: 'https://not-th.re/', + apiServer: 'https://api.not-th.re', + id: 'f1', + seed: 'seedX', + fileName: 'a.txt', + }); + expect(result.alternatives.map((row) => row.id)).to.deep.equal([ + 'ui', + 'cli', + 'docker', + 'curl', + 'powershell', + ]); + expect(result.alternatives[1].value).to.equal( + "not3 file download f1 'a.txt' --seed 'seedX'", + ); + expect(result.curl).to.equal(result.alternatives[3].value); + }); it('builds file ui and curl links', () => { const { url, curl } = fileShare({ uiUrl: 'https://not-th.re/', From 25b4ab4f89e4589a014d1212fc5b1fc2044f8d68 Mon Sep 17 00:00:00 2001 From: OMO - AI Orchestrator Date: Wed, 7 Oct 2026 00:31:30 +0200 Subject: [PATCH 2/2] feat(output): render share alternatives across commands --- README.md | 24 ++++- src/commands/file/upload.ts | 4 +- src/commands/note/save.ts | 5 +- src/commands/p2p/send.ts | 16 ++-- src/lib/reporter.ts | 24 ++++- test/commands/file.test.ts | 38 +++++++- test/commands/note.test.ts | 38 ++++++++ test/commands/p2p.test.ts | 51 ++++++++++ test/lib/reporter.test.ts | 182 +++++++++++++++++++++++++++++++++++- 9 files changed, 365 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 70b4868..92f65ec 100644 --- a/README.md +++ b/README.md @@ -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 # 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 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 [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) @@ -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 --seed ` to restart an interrupted transfer. diff --git a/src/commands/file/upload.ts b/src/commands/file/upload.ts index 91d41cf..8de4d1c 100644 --- a/src/commands/file/upload.ts +++ b/src/commands/file/upload.ts @@ -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({ @@ -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 }); diff --git a/src/commands/note/save.ts b/src/commands/note/save.ts index be895a3..227f24b 100644 --- a/src/commands/note/save.ts +++ b/src/commands/note/save.ts @@ -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 = { @@ -55,6 +56,7 @@ export default class NoteSave extends BaseCommand { id: note.id, seed, mode: s.mode, + fileName: flags.file, }); reporter.share({ kind: 'note', @@ -63,6 +65,7 @@ export default class NoteSave extends BaseCommand { seedGenerated, url: share.url, curl: share.curl, + alternatives: share.alternatives, }); } } diff --git a/src/commands/p2p/send.ts b/src/commands/p2p/send.ts index 55b4e57..ede3667 100644 --- a/src/commands/p2p/send.ts +++ b/src/commands/p2p/send.ts @@ -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 ', @@ -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)', diff --git a/src/lib/reporter.ts b/src/lib/reporter.ts index 0bfeff7..3da40f5 100644 --- a/src/lib/reporter.ts +++ b/src/lib/reporter.ts @@ -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'; @@ -18,6 +19,7 @@ export interface ShareInfo { seedGenerated: boolean; url: string; curl?: string; + alternatives: ShareAlternative[]; } export interface Progress { @@ -54,23 +56,39 @@ export class Reporter { } share(info: ShareInfo): void { + const rows = info.alternatives.filter((row) => row.id !== 'ui'); + const labels: Record = { + cli: 'CLI', + docker: 'Docker', + curl: 'cURL', + powershell: 'PS', + 'server-decrypt': 'Server', + }; + const keys: Record = { + 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': diff --git a/test/commands/file.test.ts b/test/commands/file.test.ts index 0ec94ea..707b3ab 100644 --- a/test/commands/file.test.ts +++ b/test/commands/file.test.ts @@ -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'; @@ -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'); diff --git a/test/commands/note.test.ts b/test/commands/note.test.ts index 888a264..618377f 100644 --- a/test/commands/note.test.ts +++ b/test/commands/note.test.ts @@ -2,6 +2,9 @@ import { runCommand } from '@oclif/test'; import { expect } from 'chai'; import nock from 'nock'; import { Crypto } from '@not3/sdk'; +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; const SERVER = 'http://localhost:9999'; const INFO = { @@ -40,6 +43,41 @@ describe('not3 note', () => { nock.cleanAll(); }); + it('save --file prints save variants from the SDK catalog', async () => { + const dir = mkdtempSync(join(tmpdir(), 'not3-note-share-')); + const file = join(dir, 'message.txt'); + writeFileSync(file, 'secret'); + const seed = Crypto.generateSeed(); + try { + nock(SERVER).get('/info').times(2).reply(200, INFO); + nock(SERVER) + .post('/note/json') + .reply(201, { id: 'note-file', cost: 1, deleteToken: 't' }); + const { stdout, error } = await runCommand([ + 's', + '--file', + file, + '--server', + SERVER, + '--seed', + seed, + '--output-mode', + 'simple', + ]); + expect(error).to.equal(undefined); + expect(stdout).to.contain( + `cli: not3 note get note-file --seed '${seed}' --server ${SERVER} --output '${file}'\n`, + ); + expect(stdout).to.contain( + `docker: docker run --rm -it -v "$(pwd):/data" ghcr.io/not-three/cli note get note-file --seed '${seed}' --server ${SERVER} --output '${file}'\n`, + ); + expect(stdout).to.contain('powershell: '); + expect(stdout).to.contain('server: '); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it('save encrypts, posts, and prints a share url (stdout mode)', async () => { let posted = ''; nock(SERVER).get('/info').times(2).reply(200, INFO); diff --git a/test/commands/p2p.test.ts b/test/commands/p2p.test.ts index cce4e9a..da6ed3e 100644 --- a/test/commands/p2p.test.ts +++ b/test/commands/p2p.test.ts @@ -7,6 +7,7 @@ import nock from 'nock'; import { P2PPeerDisconnectedError, P2PReceiver, + P2PSender, ShareGenerator, } from '@not3/sdk'; @@ -27,6 +28,56 @@ describe('not3 p2p send', () => { rmSync(dir, { recursive: true, force: true }); }); + it('prints link, CLI and Docker when the sender reaches waiting-peer', async () => { + const input = join(dir, 'send.txt'); + writeFileSync(input, 'hello'); + const originalStart = P2PSender.prototype.start; + const originalOnProgress = P2PSender.prototype.onProgress; + const originalSession = P2PSender.prototype.getSessionId; + const originalSeed = P2PSender.prototype.getSeed; + let progress: Parameters[0] = () => {}; + P2PSender.prototype.onProgress = function (hook) { + progress = hook; + }; + P2PSender.prototype.getSessionId = () => 'session-share'; + P2PSender.prototype.getSeed = () => 'seedX'; + P2PSender.prototype.start = async () => { + await progress({ + state: 'waiting-peer', + bytesTransferred: 0, + totalBytes: 5, + }); + }; + try { + nock(SERVER) + .get('/info') + .reply(200, { version: 'IN-DEV', p2pEnabled: true }); + const { stdout, error } = await runCommand([ + 'p2p', + 'send', + input, + '-s', + SERVER, + '--seed', + 'seedX', + '--output-mode', + 'simple', + '--no-version-check', + ]); + expect(error).to.equal(undefined); + expect(stdout).to.contain('id: session-share\n'); + expect(stdout).to.contain('cli: not3 p2p receive '); + expect(stdout).to.contain('docker: docker run --rm -it '); + expect(stdout).to.not.contain('curl:'); + expect(stdout).to.not.contain('powershell:'); + } finally { + P2PSender.prototype.start = originalStart; + P2PSender.prototype.onProgress = originalOnProgress; + P2PSender.prototype.getSessionId = originalSession; + P2PSender.prototype.getSeed = originalSeed; + } + }); + it('reports disabled P2P on the server with exit 1', async () => { const input = join(dir, 'in.txt'); writeFileSync(input, 'hello'); diff --git a/test/lib/reporter.test.ts b/test/lib/reporter.test.ts index ff88157..b1a6c04 100644 --- a/test/lib/reporter.test.ts +++ b/test/lib/reporter.test.ts @@ -1,5 +1,7 @@ import { expect } from 'chai'; import { Reporter, resolveOutputMode, ShareInfo } from '../../src/lib/reporter'; +import { ShareAlternative } from '@not3/sdk'; +import { fileShare, noteShare, p2pShare } from '../../src/lib/share'; class Sink { data = ''; @@ -22,6 +24,44 @@ const share: ShareInfo = { seedGenerated: true, url: 'https://not-th.re/abc123#seedX', curl: 'curl ...', + alternatives: [ + { + id: 'ui', + label: 'Link', + description: 'Open in browser.', + value: 'https://not-th.re/abc123#seedX', + }, + { + id: 'cli', + label: 'CLI', + description: 'Needs CLI.', + value: "not3 note get abc123 --seed 'seedX'", + }, + { + id: 'docker', + label: 'Docker', + description: 'Needs Docker.', + value: 'docker run ...', + }, + { + id: 'curl', + label: 'cURL', + description: 'Needs curl.', + value: 'curl ...', + }, + { + id: 'powershell', + label: 'PowerShell', + description: 'Needs PowerShell.', + value: 'powershell ...', + }, + { + id: 'server-decrypt', + label: 'Server-side decrypt', + description: 'Server sees key.', + value: 'https://api.not-th.re/decrypt', + }, + ] as ShareAlternative[], }; describe('resolveOutputMode', () => { @@ -41,14 +81,36 @@ describe('Reporter.share', () => { seed: 'restart-seed', seedGenerated: true, url: 'https://not-th.re/f/session-1#seed', + alternatives: [ + { + id: 'ui', + label: 'Link', + description: '', + value: 'https://not-th.re/f/session-1#seed', + }, + { + id: 'cli', + label: 'CLI', + description: '', + value: 'not3 p2p receive link', + }, + { + id: 'docker', + label: 'Docker', + description: '', + value: 'docker run receive', + }, + ] as ShareAlternative[], }; - it('pretty mode renders a P2P QR without a curl command', () => { + it('pretty mode renders a P2P QR and its available commands', () => { const { out, r } = make('pretty'); r.share(p2p); expect(out.data).to.contain(p2p.url); expect(out.data.length).to.be.greaterThan(500); expect(out.data).to.not.contain('cURL'); + expect(out.data).to.contain('not3 p2p receive link'); + expect(out.data).to.contain('docker run receive'); }); it('simple mode prints the P2P URL and seed without curl', () => { @@ -98,6 +160,124 @@ describe('Reporter.share', () => { r.share(share); expect(out.data).to.contain('id: abc123'); expect(out.data).to.not.contain('\x1b['); + expect(out.data).to.equal( + "id: abc123\nseed: seedX\nurl: https://not-th.re/abc123#seedX\ncli: not3 note get abc123 --seed 'seedX'\ndocker: docker run ...\ncurl: curl ...\npowershell: powershell ...\nserver: https://api.not-th.re/decrypt\n", + ); + }); + + it('pretty mode displays ordered padded labels and exact catalog values', () => { + const { out, r } = make('pretty'); + r.share(share); + const lines = out.data + .split('\n') + .filter((line) => line.includes('\x1b[2m')); + expect(lines).to.deep.equal([ + ' \x1b[2mID \x1b[0m \x1b[36mabc123\x1b[0m', + ' \x1b[2mSeed \x1b[0m \x1b[36mseedX\x1b[0m', + ' \x1b[2mURL \x1b[0m \x1b[36mhttps://not-th.re/abc123#seedX\x1b[0m', + " \x1b[2mCLI \x1b[0m \x1b[36mnot3 note get abc123 --seed 'seedX'\x1b[0m", + ' \x1b[2mDocker\x1b[0m \x1b[36mdocker run ...\x1b[0m', + ' \x1b[2mcURL \x1b[0m \x1b[36mcurl ...\x1b[0m', + ' \x1b[2mPS \x1b[0m \x1b[36mpowershell ...\x1b[0m', + ' \x1b[2mServer\x1b[0m \x1b[36mhttps://api.not-th.re/decrypt\x1b[0m', + ]); + }); + + for (const [kind, result] of [ + [ + 'note', + noteShare({ + uiUrl: 'https://not-th.re/', + apiServer: 'https://api.not-th.re', + id: 'n1', + seed: 'seedX', + mode: 'cbc', + }), + ], + [ + 'file', + fileShare({ + uiUrl: 'https://not-th.re/', + apiServer: 'https://api.not-th.re', + id: 'f1', + seed: 'seedX', + fileName: 'a.txt', + }), + ], + [ + 'p2p', + p2pShare({ + uiUrl: 'https://not-th.re/', + apiServer: 'https://api.not-th.re', + id: 's1', + seed: 'seedX', + }), + ], + ] as const) { + for (const mode of ['pretty', 'simple'] as const) { + it(`${kind} ${mode} prints every SDK alternative in catalog order`, () => { + const { out, r } = make(mode); + r.share({ + kind, + id: 'id', + seed: 'seedX', + seedGenerated: false, + ...result, + }); + const values = result.alternatives.map((row) => row.value); + let last = -1; + for (const value of values) { + const next = out.data.indexOf(value, last + 1); + expect( + next, + `missing or unordered value: ${value}`, + ).to.be.greaterThan(last); + last = next; + } + expect(out.data).to.not.contain('undefined:'); + }); + } + for (const mode of ['stdout', 'raw'] as const) { + for (const seedGenerated of [true, false]) { + it(`${kind} ${mode} keeps exact machine bytes with ${seedGenerated ? 'generated' : 'supplied'} seed`, () => { + const { out, err, r } = make(mode); + r.share({ kind, id: 'id', seed: 'seedX', seedGenerated, ...result }); + expect(out.data).to.equal(mode === 'stdout' ? result.url : 'id'); + expect(err.data).to.equal( + seedGenerated + ? mode === 'stdout' + ? 'seed: seedX\n' + : 'seedX' + : '', + ); + }); + } + } + } + + it('GCM notes print only CLI and Docker commands after the URL', () => { + const result = noteShare({ + uiUrl: 'https://not-th.re/', + apiServer: 'https://api.not-th.re', + id: 'n1', + seed: 'seedX', + mode: 'gcm', + }); + const { out, r } = make('simple'); + r.share({ + kind: 'note', + id: 'n1', + seed: 'seedX', + seedGenerated: false, + ...result, + }); + expect( + out.data + .split('\n') + .filter(Boolean) + .map((line) => line.split(':')[0]), + ).to.deep.equal(['id', 'seed', 'url', 'cli', 'docker']); + expect(out.data).to.contain('--mode gcm\n'); }); });