lpgp 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/pgp-tool.js CHANGED
@@ -5,1121 +5,1329 @@ import { execSync } from 'child_process';
5
5
  import * as readline from 'readline';
6
6
  import { stdin as input, stdout as output } from 'process';
7
7
  import clipboardy from 'clipboardy';
8
+ import { Command } from 'commander';
8
9
  import { Db } from './db.js';
9
10
  import { KeyManager } from './key-manager.js';
10
11
  import { extractPublicKeyInfo } from './key-utils.js';
11
12
  import { escapeablePrompt, enableGlobalEscape, checkAndResetEscape, EscapeError, } from './prompts.js';
12
13
  import { getStoredPassphrase, storePassphrase, hasStoredPassphrase, } from './keychain.js';
13
14
  import { colors, icons, printBanner, printDivider, showSuccess, showError, showWarning, showLoading, promptMessage, mainMenuChoice, backChoice, exitChoice, } from './ui.js';
14
- // Config to allow weak keys like DSA (not recommended for production)
15
- const weakKeyConfig = {
16
- rejectPublicKeyAlgorithms: new Set(),
17
- rejectHashAlgorithms: new Set(),
18
- rejectMessageHashAlgorithms: new Set(),
19
- rejectCurves: new Set(),
20
- };
21
- // Database and key manager (initialized in main())
22
- let db;
23
- let keyManager;
24
- // Session passphrase cache - stores passphrases by keypair ID
25
- const passphraseCache = new Map();
26
- // Check if lpgp is installed globally (not running via npx/pnpx)
27
- function isInstalledGlobally() {
15
+ import { generateCommand, exportPublicCommand, listKeysCommand, encryptCommand, decryptCommand, } from './cli-commands.js';
16
+ import { readFileSync } from 'fs';
17
+ import { dirname, join } from 'path';
18
+ import { fileURLToPath } from 'url';
19
+ // Get package version
20
+ const __filename = fileURLToPath(import.meta.url);
21
+ const __dirname = dirname(__filename);
22
+ function getPackageVersion() {
28
23
  try {
29
- const result = execSync('which lpgp 2>/dev/null || where lpgp 2>nul', {
30
- encoding: 'utf-8',
31
- stdio: ['pipe', 'pipe', 'pipe'],
32
- });
33
- return result.trim().length > 0;
24
+ const pkgPath = join(__dirname, '..', 'package.json');
25
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
26
+ return pkg.version;
34
27
  }
35
28
  catch {
36
- return false;
29
+ return '0.0.0';
37
30
  }
38
31
  }
39
- // Install lpgp globally
40
- async function installGlobally() {
41
- console.clear();
42
- printBanner();
43
- console.log();
44
- showLoading('Installing lpgp globally...');
45
- console.log();
46
- console.log(colors.muted('Running: npm install -g lpgp'));
47
- console.log();
48
- try {
49
- execSync('npm install -g lpgp', { stdio: 'inherit' });
50
- console.log();
51
- showSuccess('lpgp installed globally! You can now run it with just "lpgp"');
52
- console.log();
53
- return true;
54
- }
55
- catch {
56
- console.log();
57
- showError('Failed to install globally. You may need to run with sudo:');
58
- console.log(colors.muted(' sudo npm install -g lpgp'));
59
- console.log();
32
+ // Helper: Collect multiple --to values
33
+ function collect(value, previous) {
34
+ return previous.concat([value]);
35
+ }
36
+ // Check if running in CLI mode (has subcommand arguments)
37
+ function isCLIMode() {
38
+ // If we have arguments beyond the script name that look like commands
39
+ const args = process.argv.slice(2);
40
+ if (args.length === 0)
60
41
  return false;
61
- }
42
+ // Check if first arg is a known command or starts with -
43
+ const commands = [
44
+ 'generate',
45
+ 'export-public',
46
+ 'list-keys',
47
+ 'encrypt',
48
+ 'decrypt',
49
+ '--help',
50
+ '-h',
51
+ '--version',
52
+ '-V',
53
+ ];
54
+ return commands.some((cmd) => args[0] === cmd || args[0]?.startsWith('-'));
62
55
  }
63
- async function encryptMessage(message, publicKeysArmored) {
64
- let publicKeys;
65
- if (publicKeysArmored) {
66
- // Use provided public key(s)
67
- const keysArray = Array.isArray(publicKeysArmored) ? publicKeysArmored : [publicKeysArmored];
68
- publicKeys = await Promise.all(keysArray.map((key) => openpgp.readKey({ armoredKey: key, config: weakKeyConfig })));
69
- }
70
- else {
71
- // Use default keypair's public key (encrypt to self)
72
- const defaultKeypair = await keyManager.getDefaultKeypair();
73
- if (!defaultKeypair) {
74
- throw new Error('No default keypair found. Please set up a keypair first.');
56
+ // Set up CLI commands
57
+ function setupCLI() {
58
+ const program = new Command()
59
+ .name('lpgp')
60
+ .description('PGP encryption/decryption CLI tool')
61
+ .version(getPackageVersion());
62
+ program
63
+ .command('generate')
64
+ .description('Generate a new PGP keypair')
65
+ .requiredOption('--name <name>', 'Name for the keypair')
66
+ .requiredOption('--email <email>', 'Email for the keypair')
67
+ .option('--passphrase <pass>', 'Passphrase to protect the key')
68
+ .option('--no-passphrase', 'Generate without passphrase protection')
69
+ .option('--no-set-default', 'Do not set as default keypair')
70
+ .action(generateCommand);
71
+ program
72
+ .command('export-public')
73
+ .description('Export public key to stdout')
74
+ .option('--fingerprint <fp>', 'Fingerprint of key to export (default: default keypair)')
75
+ .option('--json', 'Output as JSON with metadata')
76
+ .action(exportPublicCommand);
77
+ program
78
+ .command('list-keys')
79
+ .description('List all keypairs')
80
+ .option('--json', 'Output as JSON')
81
+ .action(listKeysCommand);
82
+ program
83
+ .command('encrypt [message]')
84
+ .description('Encrypt a message')
85
+ .requiredOption('--to <recipient>', 'Recipient fingerprint or email (can be used multiple times)', collect, [])
86
+ .option('--file <path>', 'Read message from file')
87
+ .option('--output <path>', 'Write to file (default: stdout)')
88
+ .action(encryptCommand);
89
+ program
90
+ .command('decrypt [message]')
91
+ .description('Decrypt a message')
92
+ .option('--passphrase <pass>', 'Passphrase for private key')
93
+ .option('--file <path>', 'Read encrypted message from file')
94
+ .action(decryptCommand);
95
+ program.parse();
96
+ }
97
+ // Run CLI mode if arguments provided
98
+ if (isCLIMode()) {
99
+ setupCLI();
100
+ }
101
+ else {
102
+ // Interactive mode - continue with existing behavior
103
+ startInteractiveMode();
104
+ }
105
+ function startInteractiveMode() {
106
+ // Config to allow weak keys like DSA (not recommended for production)
107
+ const weakKeyConfig = {
108
+ rejectPublicKeyAlgorithms: new Set(),
109
+ rejectHashAlgorithms: new Set(),
110
+ rejectMessageHashAlgorithms: new Set(),
111
+ rejectCurves: new Set(),
112
+ };
113
+ // Database and key manager (initialized in main())
114
+ let db;
115
+ let keyManager;
116
+ // Session passphrase cache - stores passphrases by keypair ID
117
+ const passphraseCache = new Map();
118
+ // Get installed version of lpgp (null if not installed)
119
+ function getInstalledVersion() {
120
+ try {
121
+ // Check if lpgp is in PATH
122
+ execSync('which lpgp 2>/dev/null || where lpgp 2>nul', {
123
+ encoding: 'utf-8',
124
+ stdio: ['pipe', 'pipe', 'pipe'],
125
+ });
126
+ // Get the installed version
127
+ const version = execSync('npm list -g lpgp --json 2>/dev/null', {
128
+ encoding: 'utf-8',
129
+ stdio: ['pipe', 'pipe', 'pipe'],
130
+ });
131
+ const parsed = JSON.parse(version);
132
+ return parsed.dependencies?.lpgp?.version || null;
133
+ }
134
+ catch {
135
+ return null;
75
136
  }
76
- publicKeys = [await openpgp.readKey({ armoredKey: defaultKeypair.public_key, config: weakKeyConfig })];
77
- // Update last_used_at
78
- db.update('keypair', { key: 'id', value: defaultKeypair.id }, { last_used_at: new Date().toISOString() });
79
137
  }
80
- const encrypted = await openpgp.encrypt({
81
- message: await openpgp.createMessage({ text: message }),
82
- encryptionKeys: publicKeys,
83
- config: weakKeyConfig,
84
- });
85
- return encrypted;
86
- }
87
- async function decryptMessage(encryptedMessage) {
88
- const defaultKeypair = await keyManager.getDefaultKeypair();
89
- if (!defaultKeypair) {
90
- throw new Error('No default keypair found. Please set up a keypair first.');
138
+ // Get latest version from npm registry
139
+ function getLatestVersion() {
140
+ try {
141
+ const result = execSync('npm view lpgp version 2>/dev/null', {
142
+ encoding: 'utf-8',
143
+ stdio: ['pipe', 'pipe', 'pipe'],
144
+ });
145
+ return result.trim();
146
+ }
147
+ catch {
148
+ return null;
149
+ }
91
150
  }
92
- // Check if passphrase is cached for this keypair
93
- let passphrase = '';
94
- if (defaultKeypair.passphrase_protected) {
95
- if (passphraseCache.has(defaultKeypair.id)) {
96
- // Use session-cached passphrase
97
- passphrase = passphraseCache.get(defaultKeypair.id);
151
+ // Detect which package manager to use
152
+ function detectPackageManager() {
153
+ try {
154
+ execSync('which pnpm 2>/dev/null || where pnpm 2>nul', {
155
+ stdio: ['pipe', 'pipe', 'pipe'],
156
+ });
157
+ return 'pnpm';
98
158
  }
99
- else {
100
- // Check if passphrase is stored in system keychain
101
- const storedPassphrase = await getStoredPassphrase(defaultKeypair.fingerprint);
102
- if (storedPassphrase) {
103
- // Validate the stored passphrase
104
- try {
105
- await openpgp.decryptKey({
106
- privateKey: await openpgp.readPrivateKey({ armoredKey: defaultKeypair.private_key, config: weakKeyConfig }),
107
- passphrase: storedPassphrase,
108
- config: weakKeyConfig,
109
- });
110
- // Stored passphrase is valid, use it
111
- passphrase = storedPassphrase;
112
- passphraseCache.set(defaultKeypair.id, passphrase);
113
- console.log(colors.muted('Using passphrase from system keychain'));
114
- }
115
- catch {
116
- // Stored passphrase is invalid (key may have changed), prompt for new one
117
- showWarning('Stored passphrase is invalid. Please enter your passphrase.');
118
- }
159
+ catch {
160
+ try {
161
+ execSync('which yarn 2>/dev/null || where yarn 2>nul', {
162
+ stdio: ['pipe', 'pipe', 'pipe'],
163
+ });
164
+ return 'yarn';
119
165
  }
120
- // If we still don't have a valid passphrase, prompt for it
121
- if (!passphrase) {
122
- const { passphraseInput } = await escapeablePrompt([
123
- {
124
- type: 'password',
125
- name: 'passphraseInput',
126
- message: promptMessage('Enter your private key passphrase:'),
127
- mask: '*',
128
- },
129
- ]);
130
- passphrase = passphraseInput;
131
- // Validate the passphrase by attempting to decrypt the key
132
- try {
133
- await openpgp.decryptKey({
134
- privateKey: await openpgp.readPrivateKey({ armoredKey: defaultKeypair.private_key, config: weakKeyConfig }),
135
- passphrase,
136
- config: weakKeyConfig,
137
- });
138
- // If successful, cache the passphrase in session
139
- passphraseCache.set(defaultKeypair.id, passphrase);
140
- // Ask if user wants to save passphrase to system keychain
141
- const alreadyStored = await hasStoredPassphrase(defaultKeypair.fingerprint);
142
- if (!alreadyStored) {
143
- const { saveToKeychain } = await escapeablePrompt([
144
- {
145
- type: 'confirm',
146
- name: 'saveToKeychain',
147
- message: promptMessage('Save passphrase to system keychain?'),
148
- default: false,
149
- },
150
- ]);
151
- if (saveToKeychain) {
152
- const saved = await storePassphrase(defaultKeypair.fingerprint, passphrase);
153
- if (saved) {
154
- showSuccess('Passphrase saved to system keychain');
155
- }
156
- else {
157
- showWarning('Could not save to keychain (may not be available on this system)');
158
- }
159
- }
160
- }
161
- }
162
- catch (error) {
163
- throw new Error('Incorrect passphrase');
164
- }
166
+ catch {
167
+ return 'npm';
165
168
  }
166
169
  }
167
170
  }
168
- const privateKey = await openpgp.decryptKey({
169
- privateKey: await openpgp.readPrivateKey({ armoredKey: defaultKeypair.private_key, config: weakKeyConfig }),
170
- passphrase,
171
- config: weakKeyConfig,
172
- });
173
- const message = await openpgp.readMessage({
174
- armoredMessage: encryptedMessage,
175
- });
176
- const { data: decrypted } = await openpgp.decrypt({
177
- message,
178
- decryptionKeys: privateKey,
179
- config: weakKeyConfig,
180
- });
181
- // Update last_used_at
182
- db.update('keypair', { key: 'id', value: defaultKeypair.id }, { last_used_at: new Date().toISOString() });
183
- return decrypted;
184
- }
185
- function checkEditorAvailable(command) {
186
- try {
187
- execSync(`which ${command}`, { stdio: 'ignore' });
188
- return true;
189
- }
190
- catch {
171
+ // Compare semver versions (returns true if v1 < v2)
172
+ function isOlderVersion(v1, v2) {
173
+ const p1 = v1.split('.').map(Number);
174
+ const p2 = v2.split('.').map(Number);
175
+ for (let i = 0; i < 3; i++) {
176
+ if ((p1[i] || 0) < (p2[i] || 0))
177
+ return true;
178
+ if ((p1[i] || 0) > (p2[i] || 0))
179
+ return false;
180
+ }
191
181
  return false;
192
182
  }
193
- }
194
- function detectAvailableEditors() {
195
- const editors = [
196
- { name: 'VS Code', command: 'code', available: false },
197
- { name: 'Neovim', command: 'nvim', available: false },
198
- { name: 'Vim', command: 'vim', available: false },
199
- { name: 'Nano', command: 'nano', available: false },
200
- { name: 'Emacs', command: 'emacs', available: false },
201
- ];
202
- // Check platform specific editors
203
- if (process.platform === 'darwin') {
204
- editors.push({ name: 'TextEdit', command: 'open -e', available: true });
205
- }
206
- else if (process.platform === 'win32') {
207
- editors.push({ name: 'Notepad', command: 'notepad', available: true });
183
+ // Install or update lpgp globally
184
+ async function installOrUpdateGlobally(isUpdate) {
185
+ console.clear();
186
+ printBanner();
187
+ console.log();
188
+ const pm = detectPackageManager();
189
+ const action = isUpdate ? 'Updating' : 'Installing';
190
+ const cmd = pm === 'yarn' ? `yarn global add lpgp` : `${pm} install -g lpgp`;
191
+ showLoading(`${action} lpgp globally...`);
192
+ console.log();
193
+ console.log(colors.muted(`Running: ${cmd}`));
194
+ console.log();
195
+ try {
196
+ execSync(cmd, { stdio: 'inherit' });
197
+ console.log();
198
+ if (isUpdate) {
199
+ showSuccess('lpgp updated successfully!');
200
+ }
201
+ else {
202
+ showSuccess('lpgp installed globally! You can now run it with just "lpgp"');
203
+ }
204
+ console.log();
205
+ return true;
206
+ }
207
+ catch {
208
+ console.log();
209
+ showError(`Failed to ${isUpdate ? 'update' : 'install'}. You may need to run with sudo:`);
210
+ console.log(colors.muted(` sudo ${cmd}`));
211
+ console.log();
212
+ return false;
213
+ }
208
214
  }
209
- // Check which editors are available
210
- for (const editor of editors) {
211
- if (editor.command.includes('open -e') || editor.command === 'notepad') {
212
- editor.available = true; // TextEdit and Notepad are always available on their platforms
215
+ async function encryptMessage(message, publicKeysArmored) {
216
+ let publicKeys;
217
+ if (publicKeysArmored) {
218
+ // Use provided public key(s)
219
+ const keysArray = Array.isArray(publicKeysArmored)
220
+ ? publicKeysArmored
221
+ : [publicKeysArmored];
222
+ publicKeys = await Promise.all(keysArray.map((key) => openpgp.readKey({ armoredKey: key, config: weakKeyConfig })));
213
223
  }
214
224
  else {
215
- editor.available = checkEditorAvailable(editor.command);
225
+ // Use default keypair's public key (encrypt to self)
226
+ const defaultKeypair = await keyManager.getDefaultKeypair();
227
+ if (!defaultKeypair) {
228
+ throw new Error('No default keypair found. Please set up a keypair first.');
229
+ }
230
+ publicKeys = [
231
+ await openpgp.readKey({
232
+ armoredKey: defaultKeypair.public_key,
233
+ config: weakKeyConfig,
234
+ }),
235
+ ];
236
+ // Update last_used_at
237
+ db.update('keypair', { key: 'id', value: defaultKeypair.id }, { last_used_at: new Date().toISOString() });
216
238
  }
239
+ const encrypted = await openpgp.encrypt({
240
+ message: await openpgp.createMessage({ text: message }),
241
+ encryptionKeys: publicKeys,
242
+ config: weakKeyConfig,
243
+ });
244
+ return encrypted;
217
245
  }
218
- return editors.filter((e) => e.available);
219
- }
220
- async function readInlineMultilineInput(promptText) {
221
- console.log(promptMessage(promptText));
222
- console.log(colors.muted('(Type your message. Press Enter, then Ctrl+D to finish)\n'));
223
- const rl = readline.createInterface({ input, output });
224
- rl.setPrompt('');
225
- const lines = [];
226
- return new Promise((resolve) => {
227
- rl.on('line', (line) => {
228
- lines.push(line);
246
+ async function decryptMessage(encryptedMessage) {
247
+ const defaultKeypair = await keyManager.getDefaultKeypair();
248
+ if (!defaultKeypair) {
249
+ throw new Error('No default keypair found. Please set up a keypair first.');
250
+ }
251
+ // Check if passphrase is cached for this keypair
252
+ let passphrase = '';
253
+ if (defaultKeypair.passphrase_protected) {
254
+ if (passphraseCache.has(defaultKeypair.id)) {
255
+ // Use session-cached passphrase
256
+ passphrase = passphraseCache.get(defaultKeypair.id);
257
+ }
258
+ else {
259
+ // Check if passphrase is stored in system keychain
260
+ const storedPassphrase = await getStoredPassphrase(defaultKeypair.fingerprint);
261
+ if (storedPassphrase) {
262
+ // Validate the stored passphrase
263
+ try {
264
+ await openpgp.decryptKey({
265
+ privateKey: await openpgp.readPrivateKey({
266
+ armoredKey: defaultKeypair.private_key,
267
+ config: weakKeyConfig,
268
+ }),
269
+ passphrase: storedPassphrase,
270
+ config: weakKeyConfig,
271
+ });
272
+ // Stored passphrase is valid, use it
273
+ passphrase = storedPassphrase;
274
+ passphraseCache.set(defaultKeypair.id, passphrase);
275
+ console.log(colors.muted('Using passphrase from system keychain'));
276
+ }
277
+ catch {
278
+ // Stored passphrase is invalid (key may have changed), prompt for new one
279
+ showWarning('Stored passphrase is invalid. Please enter your passphrase.');
280
+ }
281
+ }
282
+ // If we still don't have a valid passphrase, prompt for it
283
+ if (!passphrase) {
284
+ const { passphraseInput } = await escapeablePrompt([
285
+ {
286
+ type: 'password',
287
+ name: 'passphraseInput',
288
+ message: promptMessage('Enter your private key passphrase:'),
289
+ mask: '*',
290
+ },
291
+ ]);
292
+ passphrase = passphraseInput;
293
+ // Validate the passphrase by attempting to decrypt the key
294
+ try {
295
+ await openpgp.decryptKey({
296
+ privateKey: await openpgp.readPrivateKey({
297
+ armoredKey: defaultKeypair.private_key,
298
+ config: weakKeyConfig,
299
+ }),
300
+ passphrase,
301
+ config: weakKeyConfig,
302
+ });
303
+ // If successful, cache the passphrase in session
304
+ passphraseCache.set(defaultKeypair.id, passphrase);
305
+ // Ask if user wants to save passphrase to system keychain
306
+ const alreadyStored = await hasStoredPassphrase(defaultKeypair.fingerprint);
307
+ if (!alreadyStored) {
308
+ const { saveToKeychain } = await escapeablePrompt([
309
+ {
310
+ type: 'confirm',
311
+ name: 'saveToKeychain',
312
+ message: promptMessage('Save passphrase to system keychain?'),
313
+ default: false,
314
+ },
315
+ ]);
316
+ if (saveToKeychain) {
317
+ const saved = await storePassphrase(defaultKeypair.fingerprint, passphrase);
318
+ if (saved) {
319
+ showSuccess('Passphrase saved to system keychain');
320
+ }
321
+ else {
322
+ showWarning('Could not save to keychain (may not be available on this system)');
323
+ }
324
+ }
325
+ }
326
+ }
327
+ catch (error) {
328
+ throw new Error('Incorrect passphrase');
329
+ }
330
+ }
331
+ }
332
+ }
333
+ const privateKey = await openpgp.decryptKey({
334
+ privateKey: await openpgp.readPrivateKey({
335
+ armoredKey: defaultKeypair.private_key,
336
+ config: weakKeyConfig,
337
+ }),
338
+ passphrase,
339
+ config: weakKeyConfig,
229
340
  });
230
- rl.on('close', () => {
231
- resolve(lines.join('\n'));
341
+ const message = await openpgp.readMessage({
342
+ armoredMessage: encryptedMessage,
232
343
  });
233
- });
234
- }
235
- function extractAllPublicKeys(content) {
236
- const keyRegex = /-----BEGIN PGP PUBLIC KEY BLOCK-----[\s\S]*?-----END PGP PUBLIC KEY BLOCK-----/g;
237
- const matches = content.match(keyRegex);
238
- return matches || [];
239
- }
240
- async function addKeysFromClipboard(recipients) {
241
- let clipboardContent = '';
242
- try {
243
- clipboardContent = await clipboardy.read();
244
- }
245
- catch {
246
- showWarning('Could not access clipboard');
247
- return 0;
248
- }
249
- const keys = extractAllPublicKeys(clipboardContent);
250
- if (keys.length === 0) {
251
- showWarning('No public keys found in clipboard');
252
- return 0;
344
+ const { data: decrypted } = await openpgp.decrypt({
345
+ message,
346
+ decryptionKeys: privateKey,
347
+ config: weakKeyConfig,
348
+ });
349
+ // Update last_used_at
350
+ db.update('keypair', { key: 'id', value: defaultKeypair.id }, { last_used_at: new Date().toISOString() });
351
+ return decrypted;
253
352
  }
254
- let addedCount = 0;
255
- for (const publicKey of keys) {
353
+ function checkEditorAvailable(command) {
256
354
  try {
257
- // Validate the key
258
- await openpgp.readKey({ armoredKey: publicKey, config: weakKeyConfig });
259
- const keyInfo = await extractPublicKeyInfo(publicKey);
260
- const recipientName = keyInfo.email || keyInfo.fingerprint?.slice(-8) || 'Unknown';
261
- // Check for duplicates
262
- const isDuplicate = recipients.some((r) => r.publicKey === publicKey);
263
- if (isDuplicate) {
264
- showWarning(`Skipping duplicate key: ${recipientName}`);
265
- continue;
266
- }
267
- recipients.push({
268
- name: recipientName,
269
- publicKey,
270
- });
271
- showSuccess(`Added recipient: ${recipientName}`);
272
- addedCount++;
355
+ execSync(`which ${command}`, { stdio: 'ignore' });
356
+ return true;
273
357
  }
274
- catch (error) {
275
- showError(`Failed to parse a key: ${error instanceof Error ? error.message : 'unknown error'}`);
358
+ catch {
359
+ return false;
276
360
  }
277
361
  }
278
- return addedCount;
279
- }
280
- async function selectMultipleRecipients() {
281
- const recipients = [];
282
- const contacts = db.select({ table: 'contact' });
283
- const defaultKeypair = await keyManager.getDefaultKeypair();
284
- // Build the menu choices
285
- function buildChoices() {
286
- const choices = [];
287
- // Show current recipients count
288
- if (recipients.length > 0) {
289
- choices.push({
290
- name: colors.primary(`── Current recipients: ${recipients.length} ──`),
291
- value: 'show-recipients',
292
- });
362
+ function detectAvailableEditors() {
363
+ const editors = [
364
+ { name: 'VS Code', command: 'code', available: false },
365
+ { name: 'Neovim', command: 'nvim', available: false },
366
+ { name: 'Vim', command: 'vim', available: false },
367
+ { name: 'Nano', command: 'nano', available: false },
368
+ { name: 'Emacs', command: 'emacs', available: false },
369
+ ];
370
+ // Check platform specific editors
371
+ if (process.platform === 'darwin') {
372
+ editors.push({ name: 'TextEdit', command: 'open -e', available: true });
293
373
  }
294
- // Option to add self (if not already added)
295
- const selfAdded = recipients.some((r) => r.name === 'Myself');
296
- if (defaultKeypair && !selfAdded) {
297
- choices.push({
298
- name: `${icons.key} Add myself ${colors.muted('(so I can also decrypt)')}`,
299
- value: 'self',
300
- });
374
+ else if (process.platform === 'win32') {
375
+ editors.push({ name: 'Notepad', command: 'notepad', available: true });
301
376
  }
302
- // Option to select from contacts
303
- if (contacts.length > 0) {
304
- choices.push({
305
- name: `${icons.contact} Select from saved contacts ${colors.muted(`(${contacts.length} available)`)}`,
306
- value: 'contacts',
307
- });
377
+ // Check which editors are available
378
+ for (const editor of editors) {
379
+ if (editor.command.includes('open -e') || editor.command === 'notepad') {
380
+ editor.available = true; // TextEdit and Notepad are always available on their platforms
381
+ }
382
+ else {
383
+ editor.available = checkEditorAvailable(editor.command);
384
+ }
308
385
  }
309
- // Clipboard and manual options
310
- choices.push({
311
- name: `${icons.clipboard} Paste from clipboard ${colors.muted('(supports multiple keys)')}`,
312
- value: 'clipboard',
313
- });
314
- choices.push({
315
- name: `${icons.inline} Type/paste a single key`,
316
- value: 'manual',
317
- });
318
- // Done or cancel
319
- choices.push({
320
- name: recipients.length > 0 ? `${icons.success} Done adding recipients` : `${icons.back} Cancel`,
321
- value: 'done',
386
+ return editors.filter((e) => e.available);
387
+ }
388
+ async function readInlineMultilineInput(promptText) {
389
+ console.log(promptMessage(promptText));
390
+ console.log(colors.muted('(Type your message. Press Enter, then Ctrl+D to finish)\n'));
391
+ const rl = readline.createInterface({ input, output });
392
+ rl.setPrompt('');
393
+ const lines = [];
394
+ return new Promise((resolve) => {
395
+ rl.on('line', (line) => {
396
+ lines.push(line);
397
+ });
398
+ rl.on('close', () => {
399
+ resolve(lines.join('\n'));
400
+ });
322
401
  });
323
- return choices;
324
402
  }
325
- let addMore = true;
326
- while (addMore) {
327
- const { addMethod } = await escapeablePrompt([
328
- {
329
- type: 'list',
330
- name: 'addMethod',
331
- message: promptMessage('Add recipients:'),
332
- choices: buildChoices(),
333
- },
334
- ]);
335
- if (addMethod === 'done') {
336
- addMore = false;
403
+ function extractAllPublicKeys(content) {
404
+ const keyRegex = /-----BEGIN PGP PUBLIC KEY BLOCK-----[\s\S]*?-----END PGP PUBLIC KEY BLOCK-----/g;
405
+ const matches = content.match(keyRegex);
406
+ return matches || [];
407
+ }
408
+ async function addKeysFromClipboard(recipients) {
409
+ let clipboardContent = '';
410
+ try {
411
+ clipboardContent = await clipboardy.read();
337
412
  }
338
- else if (addMethod === 'show-recipients') {
339
- // Show current recipients
340
- console.log(colors.primary('\nCurrent recipients:'));
341
- for (const r of recipients) {
342
- console.log(colors.muted(` • ${r.name}`));
343
- }
344
- console.log();
413
+ catch {
414
+ showWarning('Could not access clipboard');
415
+ return 0;
345
416
  }
346
- else if (addMethod === 'self') {
347
- if (defaultKeypair) {
417
+ const keys = extractAllPublicKeys(clipboardContent);
418
+ if (keys.length === 0) {
419
+ showWarning('No public keys found in clipboard');
420
+ return 0;
421
+ }
422
+ let addedCount = 0;
423
+ for (const publicKey of keys) {
424
+ try {
425
+ // Validate the key
426
+ await openpgp.readKey({ armoredKey: publicKey, config: weakKeyConfig });
427
+ const keyInfo = await extractPublicKeyInfo(publicKey);
428
+ const recipientName = keyInfo.email || keyInfo.fingerprint?.slice(-8) || 'Unknown';
429
+ // Check for duplicates
430
+ const isDuplicate = recipients.some((r) => r.publicKey === publicKey);
431
+ if (isDuplicate) {
432
+ showWarning(`Skipping duplicate key: ${recipientName}`);
433
+ continue;
434
+ }
348
435
  recipients.push({
349
- name: 'Myself',
350
- publicKey: defaultKeypair.public_key,
436
+ name: recipientName,
437
+ publicKey,
351
438
  });
352
- showSuccess('Added yourself as a recipient');
439
+ showSuccess(`Added recipient: ${recipientName}`);
440
+ addedCount++;
441
+ }
442
+ catch (error) {
443
+ showError(`Failed to parse a key: ${error instanceof Error ? error.message : 'unknown error'}`);
353
444
  }
354
445
  }
355
- else if (addMethod === 'contacts') {
356
- // Show contacts as a checkbox
357
- const { selectedContacts } = await escapeablePrompt([
446
+ return addedCount;
447
+ }
448
+ async function selectMultipleRecipients() {
449
+ const recipients = [];
450
+ const contacts = db.select({ table: 'contact' });
451
+ const defaultKeypair = await keyManager.getDefaultKeypair();
452
+ // Build the menu choices
453
+ function buildChoices() {
454
+ const choices = [];
455
+ // Show current recipients count
456
+ if (recipients.length > 0) {
457
+ choices.push({
458
+ name: colors.primary(`── Current recipients: ${recipients.length} ──`),
459
+ value: 'show-recipients',
460
+ });
461
+ }
462
+ // Option to add self (if not already added)
463
+ const selfAdded = recipients.some((r) => r.name === 'Myself');
464
+ if (defaultKeypair && !selfAdded) {
465
+ choices.push({
466
+ name: `${icons.key} Add myself ${colors.muted('(so I can also decrypt)')}`,
467
+ value: 'self',
468
+ });
469
+ }
470
+ // Option to select from contacts
471
+ if (contacts.length > 0) {
472
+ choices.push({
473
+ name: `${icons.contact} Select from saved contacts ${colors.muted(`(${contacts.length} available)`)}`,
474
+ value: 'contacts',
475
+ });
476
+ }
477
+ // Clipboard and manual options
478
+ choices.push({
479
+ name: `${icons.clipboard} Paste from clipboard ${colors.muted('(supports multiple keys)')}`,
480
+ value: 'clipboard',
481
+ });
482
+ choices.push({
483
+ name: `${icons.inline} Type/paste a single key`,
484
+ value: 'manual',
485
+ });
486
+ // Done or cancel
487
+ choices.push({
488
+ name: recipients.length > 0
489
+ ? `${icons.success} Done adding recipients`
490
+ : `${icons.back} Cancel`,
491
+ value: 'done',
492
+ });
493
+ return choices;
494
+ }
495
+ let addMore = true;
496
+ while (addMore) {
497
+ const { addMethod } = await escapeablePrompt([
358
498
  {
359
- type: 'checkbox',
360
- name: 'selectedContacts',
361
- message: promptMessage('Select contacts (space to toggle, enter to confirm):'),
362
- choices: contacts.map((c) => {
363
- const alreadyAdded = recipients.some((r) => r.publicKey === c.public_key);
364
- return {
365
- name: `${c.name} <${c.email || 'no email'}>${alreadyAdded ? colors.muted(' (already added)') : ''}`,
366
- value: c.id,
367
- checked: false,
368
- disabled: alreadyAdded,
369
- };
370
- }),
499
+ type: 'list',
500
+ name: 'addMethod',
501
+ message: promptMessage('Add recipients:'),
502
+ choices: buildChoices(),
371
503
  },
372
504
  ]);
373
- let addedCount = 0;
374
- for (const contactId of selectedContacts) {
375
- const contact = contacts.find((c) => c.id === contactId);
376
- if (contact) {
505
+ if (addMethod === 'done') {
506
+ addMore = false;
507
+ }
508
+ else if (addMethod === 'show-recipients') {
509
+ // Show current recipients
510
+ console.log(colors.primary('\nCurrent recipients:'));
511
+ for (const r of recipients) {
512
+ console.log(colors.muted(` • ${r.name}`));
513
+ }
514
+ console.log();
515
+ }
516
+ else if (addMethod === 'self') {
517
+ if (defaultKeypair) {
377
518
  recipients.push({
378
- name: `${contact.name} <${contact.email || 'no email'}>`,
379
- publicKey: contact.public_key,
519
+ name: 'Myself',
520
+ publicKey: defaultKeypair.public_key,
380
521
  });
381
- addedCount++;
522
+ showSuccess('Added yourself as a recipient');
382
523
  }
383
524
  }
384
- if (addedCount > 0) {
385
- showSuccess(`Added ${addedCount} contact${addedCount > 1 ? 's' : ''}`);
386
- }
387
- }
388
- else if (addMethod === 'clipboard') {
389
- const added = await addKeysFromClipboard(recipients);
390
- if (added > 0) {
391
- console.log();
392
- showSuccess(`Added ${added} recipient${added > 1 ? 's' : ''} from clipboard`);
393
- console.log();
394
- }
395
- }
396
- else if (addMethod === 'manual') {
397
- const publicKey = await getRecipientPublicKey();
398
- if (publicKey) {
399
- try {
400
- const keyInfo = await extractPublicKeyInfo(publicKey);
401
- const recipientName = keyInfo.email || keyInfo.fingerprint?.slice(-8) || 'Unknown';
402
- // Check for duplicates
403
- const isDuplicate = recipients.some((r) => r.publicKey === publicKey);
404
- if (isDuplicate) {
405
- showWarning('This recipient is already in the list');
406
- }
407
- else {
525
+ else if (addMethod === 'contacts') {
526
+ // Show contacts as a checkbox
527
+ const { selectedContacts } = await escapeablePrompt([
528
+ {
529
+ type: 'checkbox',
530
+ name: 'selectedContacts',
531
+ message: promptMessage('Select contacts (space to toggle, enter to confirm):'),
532
+ choices: contacts.map((c) => {
533
+ const alreadyAdded = recipients.some((r) => r.publicKey === c.public_key);
534
+ return {
535
+ name: `${c.name} <${c.email || 'no email'}>${alreadyAdded ? colors.muted(' (already added)') : ''}`,
536
+ value: c.id,
537
+ checked: false,
538
+ disabled: alreadyAdded,
539
+ };
540
+ }),
541
+ },
542
+ ]);
543
+ let addedCount = 0;
544
+ for (const contactId of selectedContacts) {
545
+ const contact = contacts.find((c) => c.id === contactId);
546
+ if (contact) {
408
547
  recipients.push({
409
- name: recipientName,
410
- publicKey,
548
+ name: `${contact.name} <${contact.email || 'no email'}>`,
549
+ publicKey: contact.public_key,
411
550
  });
412
- showSuccess(`Added recipient: ${recipientName}`);
551
+ addedCount++;
413
552
  }
414
553
  }
415
- catch (error) {
416
- showError('Failed to parse public key');
554
+ if (addedCount > 0) {
555
+ showSuccess(`Added ${addedCount} contact${addedCount > 1 ? 's' : ''}`);
417
556
  }
418
557
  }
419
- }
420
- }
421
- return recipients;
422
- }
423
- async function getRecipientPublicKey() {
424
- // Check clipboard for public key
425
- let clipboardContent = '';
426
- let hasPublicKeyInClipboard = false;
427
- try {
428
- clipboardContent = await clipboardy.read();
429
- hasPublicKeyInClipboard = clipboardContent.includes('BEGIN PGP PUBLIC KEY BLOCK');
430
- }
431
- catch (e) {
432
- // Clipboard not available, continue without it
433
- }
434
- let publicKey = '';
435
- // If public key found in clipboard, ask if user wants to use it
436
- if (hasPublicKeyInClipboard) {
437
- const { useClipboard } = await escapeablePrompt([
438
- {
439
- type: 'confirm',
440
- name: 'useClipboard',
441
- message: 'Public key detected in clipboard. Use it?',
442
- default: true,
443
- },
444
- ]);
445
- if (useClipboard) {
446
- const publicMatch = clipboardContent.match(/-----BEGIN PGP PUBLIC KEY BLOCK-----[\s\S]*?-----END PGP PUBLIC KEY BLOCK-----/);
447
- if (publicMatch) {
448
- publicKey = publicMatch[0];
558
+ else if (addMethod === 'clipboard') {
559
+ const added = await addKeysFromClipboard(recipients);
560
+ if (added > 0) {
561
+ console.log();
562
+ showSuccess(`Added ${added} recipient${added > 1 ? 's' : ''} from clipboard`);
563
+ console.log();
564
+ }
565
+ }
566
+ else if (addMethod === 'manual') {
567
+ const publicKey = await getRecipientPublicKey();
568
+ if (publicKey) {
569
+ try {
570
+ const keyInfo = await extractPublicKeyInfo(publicKey);
571
+ const recipientName = keyInfo.email || keyInfo.fingerprint?.slice(-8) || 'Unknown';
572
+ // Check for duplicates
573
+ const isDuplicate = recipients.some((r) => r.publicKey === publicKey);
574
+ if (isDuplicate) {
575
+ showWarning('This recipient is already in the list');
576
+ }
577
+ else {
578
+ recipients.push({
579
+ name: recipientName,
580
+ publicKey,
581
+ });
582
+ showSuccess(`Added recipient: ${recipientName}`);
583
+ }
584
+ }
585
+ catch (error) {
586
+ showError('Failed to parse public key');
587
+ }
588
+ }
449
589
  }
450
590
  }
591
+ return recipients;
451
592
  }
452
- // If no key from clipboard, prompt for input
453
- if (!publicKey) {
454
- console.log(promptMessage('\nPaste the recipient\'s PGP PUBLIC key:'));
455
- console.log(colors.muted('(Press Enter to finish, or press Enter then Ctrl+D)\n'));
456
- const rl = readline.createInterface({ input, output });
457
- rl.setPrompt('');
458
- const lines = [];
459
- publicKey = await new Promise((resolve) => {
460
- rl.on('line', (line) => {
461
- lines.push(line);
462
- const content = lines.join('\n');
463
- // Check if we have a complete key block and current line is empty
464
- if (line.trim() === '' &&
465
- content.includes('-----BEGIN PGP PUBLIC KEY BLOCK') &&
466
- content.includes('-----END PGP PUBLIC KEY BLOCK')) {
467
- rl.close();
468
- resolve(content.trim());
593
+ async function getRecipientPublicKey() {
594
+ // Check clipboard for public key
595
+ let clipboardContent = '';
596
+ let hasPublicKeyInClipboard = false;
597
+ try {
598
+ clipboardContent = await clipboardy.read();
599
+ hasPublicKeyInClipboard = clipboardContent.includes('BEGIN PGP PUBLIC KEY BLOCK');
600
+ }
601
+ catch (e) {
602
+ // Clipboard not available, continue without it
603
+ }
604
+ let publicKey = '';
605
+ // If public key found in clipboard, ask if user wants to use it
606
+ if (hasPublicKeyInClipboard) {
607
+ const { useClipboard } = await escapeablePrompt([
608
+ {
609
+ type: 'confirm',
610
+ name: 'useClipboard',
611
+ message: 'Public key detected in clipboard. Use it?',
612
+ default: true,
613
+ },
614
+ ]);
615
+ if (useClipboard) {
616
+ const publicMatch = clipboardContent.match(/-----BEGIN PGP PUBLIC KEY BLOCK-----[\s\S]*?-----END PGP PUBLIC KEY BLOCK-----/);
617
+ if (publicMatch) {
618
+ publicKey = publicMatch[0];
469
619
  }
620
+ }
621
+ }
622
+ // If no key from clipboard, prompt for input
623
+ if (!publicKey) {
624
+ console.log(promptMessage("\nPaste the recipient's PGP PUBLIC key:"));
625
+ console.log(colors.muted('(Press Enter to finish, or press Enter then Ctrl+D)\n'));
626
+ const rl = readline.createInterface({ input, output });
627
+ rl.setPrompt('');
628
+ const lines = [];
629
+ publicKey = await new Promise((resolve) => {
630
+ rl.on('line', (line) => {
631
+ lines.push(line);
632
+ const content = lines.join('\n');
633
+ // Check if we have a complete key block and current line is empty
634
+ if (line.trim() === '' &&
635
+ content.includes('-----BEGIN PGP PUBLIC KEY BLOCK') &&
636
+ content.includes('-----END PGP PUBLIC KEY BLOCK')) {
637
+ rl.close();
638
+ resolve(content.trim());
639
+ }
640
+ });
641
+ rl.on('close', () => {
642
+ resolve(lines.join('\n'));
643
+ });
470
644
  });
471
- rl.on('close', () => {
472
- resolve(lines.join('\n'));
473
- });
474
- });
475
- }
476
- // Validate public key format
477
- if (!publicKey.includes('BEGIN PGP PUBLIC KEY BLOCK')) {
478
- console.log();
479
- showError('Invalid public key format');
480
- console.log();
481
- return null;
482
- }
483
- // Try to read the key to validate it
484
- try {
485
- await openpgp.readKey({ armoredKey: publicKey, config: weakKeyConfig });
486
- console.log();
487
- showSuccess('Valid public key');
488
- console.log();
489
- return publicKey;
490
- }
491
- catch (error) {
492
- console.log();
493
- showError(`Failed to read public key: ${error instanceof Error ? error.message : error}`);
494
- return null;
645
+ }
646
+ // Validate public key format
647
+ if (!publicKey.includes('BEGIN PGP PUBLIC KEY BLOCK')) {
648
+ console.log();
649
+ showError('Invalid public key format');
650
+ console.log();
651
+ return null;
652
+ }
653
+ // Try to read the key to validate it
654
+ try {
655
+ await openpgp.readKey({ armoredKey: publicKey, config: weakKeyConfig });
656
+ console.log();
657
+ showSuccess('Valid public key');
658
+ console.log();
659
+ return publicKey;
660
+ }
661
+ catch (error) {
662
+ console.log();
663
+ showError(`Failed to read public key: ${error instanceof Error ? error.message : error}`);
664
+ return null;
665
+ }
495
666
  }
496
- }
497
- // printBanner is imported from ui.ts
498
- function getEditorInstructions(editorCommand) {
499
- const instructions = {
500
- 'nano': 'Save: Ctrl+O, then Enter. Exit: Ctrl+X',
501
- 'vim': 'Save and exit: :wq | Cancel: :q!',
502
- 'nvim': 'Save and exit: :wq | Cancel: :q!',
503
- 'code': 'Save: Cmd/Ctrl+S, then close the editor tab',
504
- 'emacs': 'Save: Ctrl+X Ctrl+S | Exit: Ctrl+X Ctrl+C',
505
- 'open -e': 'Save: Cmd+S, then close the window',
506
- 'notepad': 'Save: Ctrl+S, then close the window',
507
- };
508
- return instructions[editorCommand] || 'Save and close the editor when done';
509
- }
510
- function clearPassphraseCache() {
511
- // Clear all cached passphrases from memory
512
- passphraseCache.clear();
513
- }
514
- async function main() {
515
- // Initialize database on first run
516
- if (!db) {
517
- db = await Db.init();
518
- keyManager = new KeyManager(db);
667
+ // printBanner is imported from ui.ts
668
+ function getEditorInstructions(editorCommand) {
669
+ const instructions = {
670
+ nano: 'Save: Ctrl+O, then Enter. Exit: Ctrl+X',
671
+ vim: 'Save and exit: :wq | Cancel: :q!',
672
+ nvim: 'Save and exit: :wq | Cancel: :q!',
673
+ code: 'Save: Cmd/Ctrl+S, then close the editor tab',
674
+ emacs: 'Save: Ctrl+X Ctrl+S | Exit: Ctrl+X Ctrl+C',
675
+ 'open -e': 'Save: Cmd+S, then close the window',
676
+ notepad: 'Save: Ctrl+S, then close the window',
677
+ };
678
+ return instructions[editorCommand] || 'Save and close the editor when done';
519
679
  }
520
- printBanner();
521
- // Check for default keypair on first run
522
- const hasKeypair = await keyManager.hasDefaultKeypair();
523
- if (!hasKeypair) {
524
- console.log();
525
- showWarning('No keypair found. Let\'s set up your first keypair.');
526
- console.log();
527
- await keyManager.setupFirstKeypair();
528
- console.log();
529
- showSuccess('Setup complete! You can now use the tool.');
530
- console.log();
680
+ function clearPassphraseCache() {
681
+ // Clear all cached passphrases from memory
682
+ passphraseCache.clear();
531
683
  }
532
- // Build menu choices
533
- const menuChoices = [
534
- { name: `${icons.encrypt} Encrypt a message`, value: 'encrypt' },
535
- { name: `${icons.decrypt} Decrypt a message`, value: 'decrypt' },
536
- { name: `${icons.key} Manage keys`, value: 'keys' },
537
- ];
538
- // Add install option if running via npx/pnpx
539
- const isGlobal = isInstalledGlobally();
540
- if (!isGlobal) {
684
+ async function main() {
685
+ // Initialize database on first run
686
+ if (!db) {
687
+ db = await Db.init();
688
+ keyManager = new KeyManager(db);
689
+ }
690
+ printBanner();
691
+ // Check for default keypair on first run
692
+ const hasKeypair = await keyManager.hasDefaultKeypair();
693
+ if (!hasKeypair) {
694
+ console.log();
695
+ showWarning("No keypair found. Let's set up your first keypair.");
696
+ console.log();
697
+ await keyManager.setupFirstKeypair();
698
+ console.log();
699
+ showSuccess('Setup complete! You can now use the tool.');
700
+ console.log();
701
+ }
702
+ // Build menu choices
703
+ const menuChoices = [
704
+ { name: `${icons.encrypt} Encrypt a message`, value: 'encrypt' },
705
+ { name: `${icons.decrypt} Decrypt a message`, value: 'decrypt' },
706
+ { name: `${icons.key} Manage keys`, value: 'keys' },
707
+ ];
708
+ // Check if installed globally and if update is available
709
+ const installedVersion = getInstalledVersion();
710
+ const latestVersion = getLatestVersion();
711
+ if (!installedVersion) {
712
+ // Not installed globally - offer to install
713
+ menuChoices.push(new inquirer.Separator());
714
+ menuChoices.push({
715
+ name: `${icons.add} Install lpgp globally ${colors.muted('(for offline use)')}`,
716
+ value: 'install',
717
+ });
718
+ }
719
+ else if (latestVersion &&
720
+ isOlderVersion(installedVersion, latestVersion)) {
721
+ // Installed but outdated - offer to update
722
+ menuChoices.push(new inquirer.Separator());
723
+ menuChoices.push({
724
+ name: `${icons.add} Update lpgp ${colors.muted(`(${installedVersion} → ${latestVersion})`)}`,
725
+ value: 'update',
726
+ });
727
+ }
541
728
  menuChoices.push(new inquirer.Separator());
542
- menuChoices.push({
543
- name: `${icons.add} Install lpgp globally ${colors.muted('(for offline use)')}`,
544
- value: 'install',
545
- });
546
- }
547
- menuChoices.push(new inquirer.Separator());
548
- menuChoices.push(exitChoice());
549
- const { action } = await escapeablePrompt([
550
- {
551
- type: 'list',
552
- name: 'action',
553
- message: promptMessage('What would you like to do?'),
554
- choices: menuChoices,
555
- },
556
- ]);
557
- if (action === 'exit') {
558
- clearPassphraseCache();
559
- console.clear();
560
- process.exit(0);
561
- }
562
- if (action === 'install') {
563
- await installGlobally();
564
- await escapeablePrompt([
729
+ menuChoices.push(exitChoice());
730
+ const { action } = await escapeablePrompt([
565
731
  {
566
- type: 'input',
567
- name: 'continue',
568
- message: promptMessage('Press Enter to continue...'),
732
+ type: 'list',
733
+ name: 'action',
734
+ message: promptMessage('What would you like to do?'),
735
+ choices: menuChoices,
569
736
  },
570
737
  ]);
571
- return main();
572
- }
573
- if (action === 'keys') {
574
- await keyManager.showKeyManagementMenu();
575
- return main();
576
- }
577
- if (action === 'encrypt') {
578
- try {
579
- // Ask who to encrypt for
580
- const { recipient } = await escapeablePrompt([
738
+ if (action === 'exit') {
739
+ clearPassphraseCache();
740
+ console.clear();
741
+ process.exit(0);
742
+ }
743
+ if (action === 'install' || action === 'update') {
744
+ await installOrUpdateGlobally(action === 'update');
745
+ await escapeablePrompt([
581
746
  {
582
- type: 'list',
583
- name: 'recipient',
584
- message: promptMessage('Who do you want to encrypt this message for?'),
585
- choices: [
586
- { name: `${icons.contact} Someone else ${colors.muted('(use their public key)')}`, value: 'other' },
587
- { name: `${icons.multiple} Multiple recipients`, value: 'multiple' },
588
- { name: `${icons.key} Myself ${colors.muted('(use my public key)')}`, value: 'self' },
589
- new inquirer.Separator(),
590
- mainMenuChoice(),
591
- ],
747
+ type: 'input',
748
+ name: 'continue',
749
+ message: promptMessage('Press Enter to continue...'),
592
750
  },
593
751
  ]);
594
- if (recipient === 'back' || recipient === 'main-menu') {
595
- return main();
596
- }
597
- let recipientPublicKeys = [];
598
- let recipientNames = [];
599
- let isNewContact = false;
600
- // Handle multiple recipients
601
- if (recipient === 'multiple') {
602
- const recipients = await selectMultipleRecipients();
603
- if (recipients.length === 0) {
604
- console.log();
605
- showError('No recipients selected. Aborting.');
606
- console.log();
752
+ return main();
753
+ }
754
+ if (action === 'keys') {
755
+ await keyManager.showKeyManagementMenu();
756
+ return main();
757
+ }
758
+ if (action === 'encrypt') {
759
+ try {
760
+ // Ask who to encrypt for
761
+ const { recipient } = await escapeablePrompt([
762
+ {
763
+ type: 'list',
764
+ name: 'recipient',
765
+ message: promptMessage('Who do you want to encrypt this message for?'),
766
+ choices: [
767
+ {
768
+ name: `${icons.contact} Someone else ${colors.muted('(use their public key)')}`,
769
+ value: 'other',
770
+ },
771
+ {
772
+ name: `${icons.multiple} Multiple recipients`,
773
+ value: 'multiple',
774
+ },
775
+ {
776
+ name: `${icons.key} Myself ${colors.muted('(use my public key)')}`,
777
+ value: 'self',
778
+ },
779
+ new inquirer.Separator(),
780
+ mainMenuChoice(),
781
+ ],
782
+ },
783
+ ]);
784
+ if (recipient === 'back' || recipient === 'main-menu') {
607
785
  return main();
608
786
  }
609
- recipientPublicKeys = recipients.map((r) => r.publicKey);
610
- recipientNames = recipients.map((r) => r.name);
611
- // Show summary
612
- console.log(colors.primary('\nEncrypting for the following recipients:'));
613
- for (const name of recipientNames) {
614
- console.log(colors.muted(` • ${name}`));
615
- }
616
- console.log();
617
- }
618
- else if (recipient === 'other') {
619
- // Check if there are any saved contacts
620
- const contacts = db.select({ table: 'contact' });
621
- // Loop for recipient selection (allows going back from contacts submenu)
622
- recipientLoop: while (true) {
623
- // Build main menu choices
624
- const recipientChoices = [];
625
- if (contacts.length > 0) {
626
- recipientChoices.push({
627
- name: `${icons.contact} Saved contacts ${colors.muted(`(${contacts.length} available)`)}`,
628
- value: 'saved-contacts',
629
- });
630
- }
631
- recipientChoices.push({ name: `${icons.add} Use a new public key`, value: 'new' }, new inquirer.Separator(), mainMenuChoice());
632
- const { recipientSource } = await escapeablePrompt([
633
- {
634
- type: 'list',
635
- name: 'recipientSource',
636
- message: promptMessage('How would you like to specify the recipient?'),
637
- choices: recipientChoices,
638
- },
639
- ]);
640
- if (recipientSource === 'main' || recipientSource === 'main-menu') {
787
+ let recipientPublicKeys = [];
788
+ let recipientNames = [];
789
+ let isNewContact = false;
790
+ // Handle multiple recipients
791
+ if (recipient === 'multiple') {
792
+ const recipients = await selectMultipleRecipients();
793
+ if (recipients.length === 0) {
794
+ console.log();
795
+ showError('No recipients selected. Aborting.');
796
+ console.log();
641
797
  return main();
642
798
  }
643
- if (recipientSource === 'saved-contacts') {
644
- // Show contacts submenu
645
- const contactChoices = contacts.map((c) => ({
646
- name: `${icons.contact} ${c.name} ${colors.muted(`<${c.email}>`)}`,
647
- value: c.id,
648
- }));
649
- contactChoices.push(new inquirer.Separator(), backChoice(), mainMenuChoice(), new inquirer.Separator());
650
- const { contactChoice } = await escapeablePrompt([
799
+ recipientPublicKeys = recipients.map((r) => r.publicKey);
800
+ recipientNames = recipients.map((r) => r.name);
801
+ // Show summary
802
+ console.log(colors.primary('\nEncrypting for the following recipients:'));
803
+ for (const name of recipientNames) {
804
+ console.log(colors.muted(` • ${name}`));
805
+ }
806
+ console.log();
807
+ }
808
+ else if (recipient === 'other') {
809
+ // Check if there are any saved contacts
810
+ const contacts = db.select({ table: 'contact' });
811
+ // Loop for recipient selection (allows going back from contacts submenu)
812
+ recipientLoop: while (true) {
813
+ // Build main menu choices
814
+ const recipientChoices = [];
815
+ if (contacts.length > 0) {
816
+ recipientChoices.push({
817
+ name: `${icons.contact} Saved contacts ${colors.muted(`(${contacts.length} available)`)}`,
818
+ value: 'saved-contacts',
819
+ });
820
+ }
821
+ recipientChoices.push({ name: `${icons.add} Use a new public key`, value: 'new' }, new inquirer.Separator(), mainMenuChoice());
822
+ const { recipientSource } = await escapeablePrompt([
651
823
  {
652
824
  type: 'list',
653
- name: 'contactChoice',
654
- message: promptMessage('Select a contact:'),
655
- choices: contactChoices,
825
+ name: 'recipientSource',
826
+ message: promptMessage('How would you like to specify the recipient?'),
827
+ choices: recipientChoices,
656
828
  },
657
829
  ]);
658
- if (contactChoice === 'main' || contactChoice === 'main-menu') {
830
+ if (recipientSource === 'main' || recipientSource === 'main-menu') {
659
831
  return main();
660
832
  }
661
- if (contactChoice === 'back') {
662
- // Go back to recipient source selection
663
- continue recipientLoop;
833
+ if (recipientSource === 'saved-contacts') {
834
+ // Show contacts submenu
835
+ const contactChoices = contacts.map((c) => ({
836
+ name: `${icons.contact} ${c.name} ${colors.muted(`<${c.email}>`)}`,
837
+ value: c.id,
838
+ }));
839
+ contactChoices.push(new inquirer.Separator(), backChoice(), mainMenuChoice(), new inquirer.Separator());
840
+ const { contactChoice } = await escapeablePrompt([
841
+ {
842
+ type: 'list',
843
+ name: 'contactChoice',
844
+ message: promptMessage('Select a contact:'),
845
+ choices: contactChoices,
846
+ },
847
+ ]);
848
+ if (contactChoice === 'main' || contactChoice === 'main-menu') {
849
+ return main();
850
+ }
851
+ if (contactChoice === 'back') {
852
+ // Go back to recipient source selection
853
+ continue recipientLoop;
854
+ }
855
+ // Use saved contact
856
+ const selectedContact = contacts.find((c) => c.id === contactChoice);
857
+ if (selectedContact) {
858
+ recipientPublicKeys = [selectedContact.public_key];
859
+ break recipientLoop;
860
+ }
664
861
  }
665
- // Use saved contact
666
- const selectedContact = contacts.find((c) => c.id === contactChoice);
667
- if (selectedContact) {
668
- recipientPublicKeys = [selectedContact.public_key];
862
+ else if (recipientSource === 'new') {
863
+ const publicKey = await getRecipientPublicKey();
864
+ if (!publicKey) {
865
+ console.log();
866
+ showError('Could not get recipient public key. Aborting.');
867
+ console.log();
868
+ return main();
869
+ }
870
+ recipientPublicKeys = [publicKey];
871
+ isNewContact = true;
669
872
  break recipientLoop;
670
873
  }
671
874
  }
672
- else if (recipientSource === 'new') {
673
- const publicKey = await getRecipientPublicKey();
674
- if (!publicKey) {
675
- console.log();
676
- showError('Could not get recipient public key. Aborting.');
677
- console.log();
678
- return main();
679
- }
680
- recipientPublicKeys = [publicKey];
681
- isNewContact = true;
682
- break recipientLoop;
683
- }
684
- }
685
- }
686
- // Detect available editors
687
- const availableEditors = detectAvailableEditors();
688
- let message;
689
- // Loop for input method selection (allows going back from editor selection)
690
- inputMethodLoop: while (true) {
691
- // Ask for input method
692
- const inputChoices = [];
693
- // Always add clipboard option first
694
- inputChoices.push({
695
- name: `${icons.clipboard} Paste from clipboard`,
696
- value: 'clipboard',
697
- });
698
- if (availableEditors.length > 0) {
699
- inputChoices.push({ name: `${icons.editor} Use an editor`, value: 'editor' }, { name: `${icons.inline} Type inline ${colors.muted('(Enter, then Ctrl+D to finish)')}`, value: 'inline' });
700
875
  }
701
- else {
876
+ // Detect available editors
877
+ const availableEditors = detectAvailableEditors();
878
+ let message;
879
+ // Loop for input method selection (allows going back from editor selection)
880
+ inputMethodLoop: while (true) {
881
+ // Ask for input method
882
+ const inputChoices = [];
883
+ // Always add clipboard option first
702
884
  inputChoices.push({
703
- name: `${icons.inline} Type inline ${colors.muted('(Enter, then Ctrl+D to finish)')}`,
704
- value: 'inline',
885
+ name: `${icons.clipboard} Paste from clipboard`,
886
+ value: 'clipboard',
705
887
  });
706
- }
707
- // Add main menu option
708
- inputChoices.push(new inquirer.Separator(), mainMenuChoice());
709
- const { inputMethod } = await escapeablePrompt([
710
- {
711
- type: 'list',
712
- name: 'inputMethod',
713
- message: promptMessage('How would you like to enter your message?'),
714
- choices: inputChoices,
715
- },
716
- ]);
717
- if (inputMethod === 'back' || inputMethod === 'main-menu') {
718
- return main();
719
- }
720
- if (inputMethod === 'clipboard') {
721
- try {
722
- message = await clipboardy.read();
723
- if (!message || message.trim() === '') {
724
- console.log();
725
- showError('Clipboard is empty.');
726
- console.log();
727
- return main();
728
- }
729
- console.log();
730
- showSuccess('Message loaded from clipboard');
731
- console.log();
732
- break inputMethodLoop;
888
+ if (availableEditors.length > 0) {
889
+ inputChoices.push({ name: `${icons.editor} Use an editor`, value: 'editor' }, {
890
+ name: `${icons.inline} Type inline ${colors.muted('(Enter, then Ctrl+D to finish)')}`,
891
+ value: 'inline',
892
+ });
733
893
  }
734
- catch (clipError) {
735
- console.log();
736
- showError(`Failed to read from clipboard: ${clipError}`);
737
- return main();
894
+ else {
895
+ inputChoices.push({
896
+ name: `${icons.inline} Type inline ${colors.muted('(Enter, then Ctrl+D to finish)')}`,
897
+ value: 'inline',
898
+ });
738
899
  }
739
- }
740
- else if (inputMethod === 'editor') {
741
- // Let user choose editor
742
- const editorChoices = availableEditors.map((e) => ({
743
- name: `${icons.editor} ${e.name} ${colors.muted(`(${getEditorInstructions(e.command)})`)}`,
744
- value: e.command,
745
- }));
746
- editorChoices.push(new inquirer.Separator(), backChoice(), mainMenuChoice());
747
- const { selectedEditor } = await escapeablePrompt([
900
+ // Add main menu option
901
+ inputChoices.push(new inquirer.Separator(), mainMenuChoice());
902
+ const { inputMethod } = await escapeablePrompt([
748
903
  {
749
904
  type: 'list',
750
- name: 'selectedEditor',
751
- message: promptMessage('Choose your editor:'),
752
- choices: editorChoices,
905
+ name: 'inputMethod',
906
+ message: promptMessage('How would you like to enter your message?'),
907
+ choices: inputChoices,
753
908
  },
754
909
  ]);
755
- if (selectedEditor === 'back') {
756
- // Re-ask for input method
757
- continue inputMethodLoop;
758
- }
759
- if (selectedEditor === 'main-menu') {
910
+ if (inputMethod === 'back' || inputMethod === 'main-menu') {
760
911
  return main();
761
912
  }
762
- // Set the EDITOR environment variable before opening inquirer editor
763
- const originalEditor = process.env.EDITOR;
764
- const originalVisual = process.env.VISUAL;
765
- process.env.EDITOR = selectedEditor;
766
- process.env.VISUAL = selectedEditor;
767
- const editorName = availableEditors.find((e) => e.command === selectedEditor)?.name || 'editor';
768
- console.log(colors.muted('\nNote: The temp file is automatically deleted after encryption.\n'));
769
- try {
770
- const { editorInput } = await escapeablePrompt([
913
+ if (inputMethod === 'clipboard') {
914
+ try {
915
+ message = await clipboardy.read();
916
+ if (!message || message.trim() === '') {
917
+ console.log();
918
+ showError('Clipboard is empty.');
919
+ console.log();
920
+ return main();
921
+ }
922
+ console.log();
923
+ showSuccess('Message loaded from clipboard');
924
+ console.log();
925
+ break inputMethodLoop;
926
+ }
927
+ catch (clipError) {
928
+ console.log();
929
+ showError(`Failed to read from clipboard: ${clipError}`);
930
+ return main();
931
+ }
932
+ }
933
+ else if (inputMethod === 'editor') {
934
+ // Let user choose editor
935
+ const editorChoices = availableEditors.map((e) => ({
936
+ name: `${icons.editor} ${e.name} ${colors.muted(`(${getEditorInstructions(e.command)})`)}`,
937
+ value: e.command,
938
+ }));
939
+ editorChoices.push(new inquirer.Separator(), backChoice(), mainMenuChoice());
940
+ const { selectedEditor } = await escapeablePrompt([
771
941
  {
772
- type: 'editor',
773
- name: 'editorInput',
774
- message: promptMessage(`Press Enter to open ${editorName}:`),
775
- postfix: '.txt',
776
- waitForUseInput: false,
942
+ type: 'list',
943
+ name: 'selectedEditor',
944
+ message: promptMessage('Choose your editor:'),
945
+ choices: editorChoices,
777
946
  },
778
947
  ]);
779
- message = editorInput;
780
- break inputMethodLoop;
781
- }
782
- finally {
783
- // Restore original environment variables
784
- if (originalEditor !== undefined) {
785
- process.env.EDITOR = originalEditor;
948
+ if (selectedEditor === 'back') {
949
+ // Re-ask for input method
950
+ continue inputMethodLoop;
786
951
  }
787
- else {
788
- delete process.env.EDITOR;
952
+ if (selectedEditor === 'main-menu') {
953
+ return main();
789
954
  }
790
- if (originalVisual !== undefined) {
791
- process.env.VISUAL = originalVisual;
955
+ // Set the EDITOR environment variable before opening inquirer editor
956
+ const originalEditor = process.env.EDITOR;
957
+ const originalVisual = process.env.VISUAL;
958
+ process.env.EDITOR = selectedEditor;
959
+ process.env.VISUAL = selectedEditor;
960
+ const editorName = availableEditors.find((e) => e.command === selectedEditor)
961
+ ?.name || 'editor';
962
+ console.log(colors.muted('\nNote: The temp file is automatically deleted after encryption.\n'));
963
+ try {
964
+ const { editorInput } = await escapeablePrompt([
965
+ {
966
+ type: 'editor',
967
+ name: 'editorInput',
968
+ message: promptMessage(`Press Enter to open ${editorName}:`),
969
+ postfix: '.txt',
970
+ waitForUseInput: false,
971
+ },
972
+ ]);
973
+ message = editorInput;
974
+ break inputMethodLoop;
792
975
  }
793
- else {
794
- delete process.env.VISUAL;
976
+ finally {
977
+ // Restore original environment variables
978
+ if (originalEditor !== undefined) {
979
+ process.env.EDITOR = originalEditor;
980
+ }
981
+ else {
982
+ delete process.env.EDITOR;
983
+ }
984
+ if (originalVisual !== undefined) {
985
+ process.env.VISUAL = originalVisual;
986
+ }
987
+ else {
988
+ delete process.env.VISUAL;
989
+ }
795
990
  }
796
991
  }
992
+ else {
993
+ message = await readInlineMultilineInput('Enter your message:');
994
+ break inputMethodLoop;
995
+ }
797
996
  }
798
- else {
799
- message = await readInlineMultilineInput('Enter your message:');
800
- break inputMethodLoop;
997
+ if (!message || message.trim() === '') {
998
+ console.log();
999
+ showError('No message provided. Aborting.');
1000
+ console.log();
1001
+ return main();
801
1002
  }
802
- }
803
- if (!message || message.trim() === '') {
804
- console.log();
805
- showError('No message provided. Aborting.');
806
- console.log();
807
- return main();
808
- }
809
- console.log();
810
- showLoading('Encrypting message...');
811
- console.log();
812
- const encrypted = await encryptMessage(message, recipientPublicKeys.length > 0 ? recipientPublicKeys : undefined);
813
- // Clear screen, show encrypted message, then clipboard status
814
- console.clear();
815
- printBanner();
816
- console.log(colors.successBold('Encrypted Message:\n'));
817
- printDivider();
818
- console.log(encrypted);
819
- printDivider();
820
- // Copy to clipboard and show status below the message
821
- try {
822
- await clipboardy.write(encrypted);
823
- console.log();
824
- showSuccess('Encrypted message copied to clipboard');
825
1003
  console.log();
826
- }
827
- catch (clipError) {
828
- console.log();
829
- showWarning('Clipboard unavailable');
1004
+ showLoading('Encrypting message...');
830
1005
  console.log();
831
- }
832
- // Offer to save the contact if it's a new public key (single recipient only)
833
- const newPublicKey = recipientPublicKeys[0];
834
- if (isNewContact && newPublicKey !== undefined && recipientPublicKeys.length === 1) {
835
- const { saveContact } = await escapeablePrompt([
836
- {
837
- type: 'confirm',
838
- name: 'saveContact',
839
- message: promptMessage('Would you like to save this contact for future use?'),
840
- default: true,
841
- },
842
- ]);
843
- if (saveContact) {
844
- try {
845
- // Extract key information
846
- const keyInfo = await extractPublicKeyInfo(newPublicKey);
847
- // Prompt for contact name
848
- const defaultName = (keyInfo.email || 'unknown').split('@')[0] || 'Contact';
849
- const answers = await escapeablePrompt([
850
- {
851
- type: 'input',
852
- name: 'contactName',
853
- message: promptMessage('Contact name:'),
854
- default: defaultName,
855
- validate: (input) => input.trim().length > 0 || 'Name cannot be empty',
856
- },
857
- ]);
858
- const contactName = answers.contactName;
859
- // Check if contact already exists by fingerprint
860
- const existingContacts = db.select({
861
- table: 'contact',
862
- where: { key: 'fingerprint', compare: 'is', value: keyInfo.fingerprint },
863
- });
864
- if (existingContacts.length > 0) {
865
- console.log();
866
- showWarning('This contact already exists.');
867
- console.log();
868
- }
869
- else {
870
- // Save the contact
871
- db.insert('contact', {
872
- name: contactName.trim(),
873
- email: keyInfo.email,
874
- fingerprint: keyInfo.fingerprint,
875
- public_key: newPublicKey,
876
- algorithm: keyInfo.algorithm,
877
- key_size: keyInfo.keySize,
878
- trusted: false,
879
- last_verified_at: null,
880
- notes: null,
881
- expires_at: keyInfo.expiresAt,
882
- revoked: false,
1006
+ const encrypted = await encryptMessage(message, recipientPublicKeys.length > 0 ? recipientPublicKeys : undefined);
1007
+ // Clear screen, show encrypted message, then clipboard status
1008
+ console.clear();
1009
+ printBanner();
1010
+ console.log(colors.successBold('Encrypted Message:\n'));
1011
+ printDivider();
1012
+ console.log(encrypted);
1013
+ printDivider();
1014
+ // Copy to clipboard and show status below the message
1015
+ try {
1016
+ await clipboardy.write(encrypted);
1017
+ console.log();
1018
+ showSuccess('Encrypted message copied to clipboard');
1019
+ console.log();
1020
+ }
1021
+ catch (clipError) {
1022
+ console.log();
1023
+ showWarning('Clipboard unavailable');
1024
+ console.log();
1025
+ }
1026
+ // Offer to save the contact if it's a new public key (single recipient only)
1027
+ const newPublicKey = recipientPublicKeys[0];
1028
+ if (isNewContact &&
1029
+ newPublicKey !== undefined &&
1030
+ recipientPublicKeys.length === 1) {
1031
+ const { saveContact } = await escapeablePrompt([
1032
+ {
1033
+ type: 'confirm',
1034
+ name: 'saveContact',
1035
+ message: promptMessage('Would you like to save this contact for future use?'),
1036
+ default: true,
1037
+ },
1038
+ ]);
1039
+ if (saveContact) {
1040
+ try {
1041
+ // Extract key information
1042
+ const keyInfo = await extractPublicKeyInfo(newPublicKey);
1043
+ // Prompt for contact name
1044
+ const defaultName = (keyInfo.email || 'unknown').split('@')[0] || 'Contact';
1045
+ const answers = await escapeablePrompt([
1046
+ {
1047
+ type: 'input',
1048
+ name: 'contactName',
1049
+ message: promptMessage('Contact name:'),
1050
+ default: defaultName,
1051
+ validate: (input) => input.trim().length > 0 || 'Name cannot be empty',
1052
+ },
1053
+ ]);
1054
+ const contactName = answers.contactName;
1055
+ // Check if contact already exists by fingerprint
1056
+ const existingContacts = db.select({
1057
+ table: 'contact',
1058
+ where: {
1059
+ key: 'fingerprint',
1060
+ compare: 'is',
1061
+ value: keyInfo.fingerprint,
1062
+ },
883
1063
  });
1064
+ if (existingContacts.length > 0) {
1065
+ console.log();
1066
+ showWarning('This contact already exists.');
1067
+ console.log();
1068
+ }
1069
+ else {
1070
+ // Save the contact
1071
+ db.insert('contact', {
1072
+ name: contactName.trim(),
1073
+ email: keyInfo.email,
1074
+ fingerprint: keyInfo.fingerprint,
1075
+ public_key: newPublicKey,
1076
+ algorithm: keyInfo.algorithm,
1077
+ key_size: keyInfo.keySize,
1078
+ trusted: false,
1079
+ last_verified_at: null,
1080
+ notes: null,
1081
+ expires_at: keyInfo.expiresAt,
1082
+ revoked: false,
1083
+ });
1084
+ console.log();
1085
+ showSuccess(`Contact "${contactName}" saved successfully!`);
1086
+ console.log();
1087
+ }
1088
+ }
1089
+ catch (error) {
884
1090
  console.log();
885
- showSuccess(`Contact "${contactName}" saved successfully!`);
886
- console.log();
1091
+ showError(`Failed to save contact: ${error instanceof Error ? error.message : error}`);
887
1092
  }
888
1093
  }
889
- catch (error) {
890
- console.log();
891
- showError(`Failed to save contact: ${error instanceof Error ? error.message : error}`);
892
- }
893
1094
  }
894
1095
  }
1096
+ catch (error) {
1097
+ // Re-throw escape errors to be handled by the main loop
1098
+ if (error instanceof EscapeError)
1099
+ throw error;
1100
+ console.log();
1101
+ showError(`Encryption failed: ${error instanceof Error ? error.message : error}`);
1102
+ }
895
1103
  }
896
- catch (error) {
897
- // Re-throw escape errors to be handled by the main loop
898
- if (error instanceof EscapeError)
899
- throw error;
900
- console.log();
901
- showError(`Encryption failed: ${error instanceof Error ? error.message : error}`);
902
- }
903
- }
904
- else if (action === 'decrypt') {
905
- try {
906
- // Detect available editors
907
- const availableEditors = detectAvailableEditors();
908
- let encrypted;
909
- // Loop for input method selection (allows going back from editor selection)
910
- decryptInputLoop: while (true) {
911
- // Ask for input method
912
- const inputChoices = [];
913
- // Always add clipboard option first
914
- inputChoices.push({
915
- name: `${icons.clipboard} Paste from clipboard`,
916
- value: 'clipboard',
917
- });
918
- if (availableEditors.length > 0) {
919
- inputChoices.push({ name: `${icons.editor} Use an editor`, value: 'editor' }, { name: `${icons.inline} Type inline ${colors.muted('(Enter, then Ctrl+D to finish)')}`, value: 'inline' });
920
- }
921
- else {
1104
+ else if (action === 'decrypt') {
1105
+ try {
1106
+ // Detect available editors
1107
+ const availableEditors = detectAvailableEditors();
1108
+ let encrypted;
1109
+ // Loop for input method selection (allows going back from editor selection)
1110
+ decryptInputLoop: while (true) {
1111
+ // Ask for input method
1112
+ const inputChoices = [];
1113
+ // Always add clipboard option first
922
1114
  inputChoices.push({
923
- name: `${icons.inline} Type inline ${colors.muted('(Enter, then Ctrl+D to finish)')}`,
924
- value: 'inline',
1115
+ name: `${icons.clipboard} Paste from clipboard`,
1116
+ value: 'clipboard',
925
1117
  });
926
- }
927
- // Add main menu option
928
- inputChoices.push(new inquirer.Separator(), mainMenuChoice());
929
- const { inputMethod } = await escapeablePrompt([
930
- {
931
- type: 'list',
932
- name: 'inputMethod',
933
- message: promptMessage('How would you like to enter the encrypted message?'),
934
- choices: inputChoices,
935
- },
936
- ]);
937
- if (inputMethod === 'back' || inputMethod === 'main-menu') {
938
- return main();
939
- }
940
- if (inputMethod === 'clipboard') {
941
- try {
942
- encrypted = await clipboardy.read();
943
- if (!encrypted || encrypted.trim() === '') {
944
- console.log();
945
- showError('Clipboard is empty.');
946
- console.log();
947
- return main();
948
- }
949
- console.log();
950
- showSuccess('Encrypted message loaded from clipboard');
951
- console.log();
952
- break decryptInputLoop;
1118
+ if (availableEditors.length > 0) {
1119
+ inputChoices.push({ name: `${icons.editor} Use an editor`, value: 'editor' }, {
1120
+ name: `${icons.inline} Type inline ${colors.muted('(Enter, then Ctrl+D to finish)')}`,
1121
+ value: 'inline',
1122
+ });
953
1123
  }
954
- catch (clipError) {
955
- console.log();
956
- showError(`Failed to read from clipboard: ${clipError}`);
957
- return main();
1124
+ else {
1125
+ inputChoices.push({
1126
+ name: `${icons.inline} Type inline ${colors.muted('(Enter, then Ctrl+D to finish)')}`,
1127
+ value: 'inline',
1128
+ });
958
1129
  }
959
- }
960
- else if (inputMethod === 'editor') {
961
- // Let user choose editor
962
- const editorChoices = availableEditors.map((e) => ({
963
- name: `${icons.editor} ${e.name} ${colors.muted(`(${getEditorInstructions(e.command)})`)}`,
964
- value: e.command,
965
- }));
966
- editorChoices.push(new inquirer.Separator(), backChoice(), mainMenuChoice());
967
- const { selectedEditor } = await escapeablePrompt([
1130
+ // Add main menu option
1131
+ inputChoices.push(new inquirer.Separator(), mainMenuChoice());
1132
+ const { inputMethod } = await escapeablePrompt([
968
1133
  {
969
1134
  type: 'list',
970
- name: 'selectedEditor',
971
- message: promptMessage('Choose your editor:'),
972
- choices: editorChoices,
1135
+ name: 'inputMethod',
1136
+ message: promptMessage('How would you like to enter the encrypted message?'),
1137
+ choices: inputChoices,
973
1138
  },
974
1139
  ]);
975
- if (selectedEditor === 'back') {
976
- // Re-ask for input method
977
- continue decryptInputLoop;
978
- }
979
- if (selectedEditor === 'main-menu') {
1140
+ if (inputMethod === 'back' || inputMethod === 'main-menu') {
980
1141
  return main();
981
1142
  }
982
- // Set the EDITOR environment variable before opening inquirer editor
983
- const originalEditor = process.env.EDITOR;
984
- const originalVisual = process.env.VISUAL;
985
- process.env.EDITOR = selectedEditor;
986
- process.env.VISUAL = selectedEditor;
987
- const editorName = availableEditors.find((e) => e.command === selectedEditor)?.name || 'editor';
988
- console.log(colors.muted('\nNote: The temp file is automatically deleted after decryption.\n'));
989
- try {
990
- const { editorInput } = await escapeablePrompt([
1143
+ if (inputMethod === 'clipboard') {
1144
+ try {
1145
+ encrypted = await clipboardy.read();
1146
+ if (!encrypted || encrypted.trim() === '') {
1147
+ console.log();
1148
+ showError('Clipboard is empty.');
1149
+ console.log();
1150
+ return main();
1151
+ }
1152
+ console.log();
1153
+ showSuccess('Encrypted message loaded from clipboard');
1154
+ console.log();
1155
+ break decryptInputLoop;
1156
+ }
1157
+ catch (clipError) {
1158
+ console.log();
1159
+ showError(`Failed to read from clipboard: ${clipError}`);
1160
+ return main();
1161
+ }
1162
+ }
1163
+ else if (inputMethod === 'editor') {
1164
+ // Let user choose editor
1165
+ const editorChoices = availableEditors.map((e) => ({
1166
+ name: `${icons.editor} ${e.name} ${colors.muted(`(${getEditorInstructions(e.command)})`)}`,
1167
+ value: e.command,
1168
+ }));
1169
+ editorChoices.push(new inquirer.Separator(), backChoice(), mainMenuChoice());
1170
+ const { selectedEditor } = await escapeablePrompt([
991
1171
  {
992
- type: 'editor',
993
- name: 'editorInput',
994
- message: promptMessage(`Press Enter to open ${editorName}:`),
995
- postfix: '.txt',
996
- waitForUseInput: false,
1172
+ type: 'list',
1173
+ name: 'selectedEditor',
1174
+ message: promptMessage('Choose your editor:'),
1175
+ choices: editorChoices,
997
1176
  },
998
1177
  ]);
999
- encrypted = editorInput;
1000
- break decryptInputLoop;
1001
- }
1002
- finally {
1003
- // Restore original environment variables
1004
- if (originalEditor !== undefined) {
1005
- process.env.EDITOR = originalEditor;
1178
+ if (selectedEditor === 'back') {
1179
+ // Re-ask for input method
1180
+ continue decryptInputLoop;
1006
1181
  }
1007
- else {
1008
- delete process.env.EDITOR;
1182
+ if (selectedEditor === 'main-menu') {
1183
+ return main();
1009
1184
  }
1010
- if (originalVisual !== undefined) {
1011
- process.env.VISUAL = originalVisual;
1185
+ // Set the EDITOR environment variable before opening inquirer editor
1186
+ const originalEditor = process.env.EDITOR;
1187
+ const originalVisual = process.env.VISUAL;
1188
+ process.env.EDITOR = selectedEditor;
1189
+ process.env.VISUAL = selectedEditor;
1190
+ const editorName = availableEditors.find((e) => e.command === selectedEditor)
1191
+ ?.name || 'editor';
1192
+ console.log(colors.muted('\nNote: The temp file is automatically deleted after decryption.\n'));
1193
+ try {
1194
+ const { editorInput } = await escapeablePrompt([
1195
+ {
1196
+ type: 'editor',
1197
+ name: 'editorInput',
1198
+ message: promptMessage(`Press Enter to open ${editorName}:`),
1199
+ postfix: '.txt',
1200
+ waitForUseInput: false,
1201
+ },
1202
+ ]);
1203
+ encrypted = editorInput;
1204
+ break decryptInputLoop;
1012
1205
  }
1013
- else {
1014
- delete process.env.VISUAL;
1206
+ finally {
1207
+ // Restore original environment variables
1208
+ if (originalEditor !== undefined) {
1209
+ process.env.EDITOR = originalEditor;
1210
+ }
1211
+ else {
1212
+ delete process.env.EDITOR;
1213
+ }
1214
+ if (originalVisual !== undefined) {
1215
+ process.env.VISUAL = originalVisual;
1216
+ }
1217
+ else {
1218
+ delete process.env.VISUAL;
1219
+ }
1015
1220
  }
1016
1221
  }
1222
+ else {
1223
+ encrypted = await readInlineMultilineInput('Paste the encrypted message:');
1224
+ break decryptInputLoop;
1225
+ }
1017
1226
  }
1018
- else {
1019
- encrypted = await readInlineMultilineInput('Paste the encrypted message:');
1020
- break decryptInputLoop;
1227
+ if (!encrypted || encrypted.trim() === '') {
1228
+ console.log();
1229
+ showError('No encrypted message provided. Aborting.');
1230
+ console.log();
1231
+ return main();
1021
1232
  }
1022
- }
1023
- if (!encrypted || encrypted.trim() === '') {
1024
- console.log();
1025
- showError('No encrypted message provided. Aborting.');
1026
- console.log();
1027
- return main();
1028
- }
1029
- console.log();
1030
- showLoading('Decrypting message...');
1031
- console.log();
1032
- const decrypted = await decryptMessage(encrypted);
1033
- // Clear screen, show decrypted message, then clipboard status
1034
- console.clear();
1035
- printBanner();
1036
- console.log(colors.successBold('Decrypted Message:\n'));
1037
- printDivider();
1038
- console.log(decrypted);
1039
- printDivider();
1040
- // Copy to clipboard and show status below the message
1041
- try {
1042
- await clipboardy.write(decrypted);
1043
1233
  console.log();
1044
- showSuccess('Decrypted message copied to clipboard');
1234
+ showLoading('Decrypting message...');
1045
1235
  console.log();
1236
+ const decrypted = await decryptMessage(encrypted);
1237
+ // Clear screen, show decrypted message, then clipboard status
1238
+ console.clear();
1239
+ printBanner();
1240
+ console.log(colors.successBold('Decrypted Message:\n'));
1241
+ printDivider();
1242
+ console.log(decrypted);
1243
+ printDivider();
1244
+ // Copy to clipboard and show status below the message
1245
+ try {
1246
+ await clipboardy.write(decrypted);
1247
+ console.log();
1248
+ showSuccess('Decrypted message copied to clipboard');
1249
+ console.log();
1250
+ }
1251
+ catch (clipError) {
1252
+ console.log();
1253
+ showWarning('Clipboard unavailable');
1254
+ console.log();
1255
+ }
1256
+ // Wait for user to press Enter before continuing
1257
+ await escapeablePrompt([
1258
+ {
1259
+ type: 'input',
1260
+ name: 'continue',
1261
+ message: colors.muted('Press Enter to continue...'),
1262
+ },
1263
+ ]);
1046
1264
  }
1047
- catch (clipError) {
1048
- console.log();
1049
- showWarning('Clipboard unavailable');
1265
+ catch (error) {
1266
+ // Re-throw escape errors to be handled by the main loop
1267
+ if (error instanceof EscapeError)
1268
+ throw error;
1050
1269
  console.log();
1270
+ showError(`Decryption failed: ${error instanceof Error ? error.message : error}`);
1051
1271
  }
1052
- // Wait for user to press Enter before continuing
1053
- await escapeablePrompt([
1054
- {
1055
- type: 'input',
1056
- name: 'continue',
1057
- message: colors.muted('Press Enter to continue...'),
1058
- },
1059
- ]);
1060
1272
  }
1061
- catch (error) {
1062
- // Re-throw escape errors to be handled by the main loop
1063
- if (error instanceof EscapeError)
1064
- throw error;
1065
- console.log();
1066
- showError(`Decryption failed: ${error instanceof Error ? error.message : error}`);
1273
+ // Ask if user wants to continue
1274
+ const { nextAction } = await escapeablePrompt([
1275
+ {
1276
+ type: 'list',
1277
+ name: 'nextAction',
1278
+ message: promptMessage('What would you like to do next?'),
1279
+ choices: [
1280
+ {
1281
+ name: `${icons.loop} Perform another operation`,
1282
+ value: 'continue',
1283
+ },
1284
+ exitChoice(),
1285
+ ],
1286
+ },
1287
+ ]);
1288
+ if (nextAction === 'continue') {
1289
+ await main();
1290
+ }
1291
+ else {
1292
+ clearPassphraseCache();
1293
+ console.clear();
1067
1294
  }
1068
1295
  }
1069
- // Ask if user wants to continue
1070
- const { nextAction } = await escapeablePrompt([
1071
- {
1072
- type: 'list',
1073
- name: 'nextAction',
1074
- message: promptMessage('What would you like to do next?'),
1075
- choices: [
1076
- { name: `${icons.loop} Perform another operation`, value: 'continue' },
1077
- exitChoice(),
1078
- ],
1079
- },
1080
- ]);
1081
- if (nextAction === 'continue') {
1082
- await main();
1083
- }
1084
- else {
1296
+ // Graceful exit on Ctrl+C
1297
+ process.on('SIGINT', () => {
1085
1298
  clearPassphraseCache();
1086
1299
  console.clear();
1087
- }
1088
- }
1089
- // Graceful exit on Ctrl+C
1090
- process.on('SIGINT', () => {
1091
- clearPassphraseCache();
1092
- console.clear();
1093
- process.exit(0);
1094
- });
1095
- // Enable global escape key handling and run menu in a loop
1096
- enableGlobalEscape();
1097
- async function runApp() {
1098
- while (true) {
1099
- try {
1100
- await main();
1101
- }
1102
- catch (error) {
1103
- const e = error;
1104
- // If escape was pressed, just restart the menu
1105
- if (error instanceof EscapeError ||
1106
- checkAndResetEscape() ||
1107
- e.message?.includes('prompt was closed')) {
1108
- continue;
1300
+ process.exit(0);
1301
+ });
1302
+ // Enable global escape key handling and run menu in a loop
1303
+ enableGlobalEscape();
1304
+ async function runApp() {
1305
+ while (true) {
1306
+ try {
1307
+ await main();
1109
1308
  }
1110
- // Handle Ctrl+C gracefully (inquirer throws ExitPromptError)
1111
- if (e.message?.includes('force closed the prompt')) {
1309
+ catch (error) {
1310
+ const e = error;
1311
+ // If escape was pressed, just restart the menu
1312
+ if (error instanceof EscapeError ||
1313
+ checkAndResetEscape() ||
1314
+ e.message?.includes('prompt was closed')) {
1315
+ continue;
1316
+ }
1317
+ // Handle Ctrl+C gracefully (inquirer throws ExitPromptError)
1318
+ if (e.message?.includes('force closed the prompt')) {
1319
+ clearPassphraseCache();
1320
+ console.clear();
1321
+ process.exit(0);
1322
+ }
1323
+ // Handle other errors
1112
1324
  clearPassphraseCache();
1113
1325
  console.clear();
1114
- process.exit(0);
1326
+ showError(`Error: ${e.message || error}`);
1327
+ process.exit(1);
1115
1328
  }
1116
- // Handle other errors
1117
- clearPassphraseCache();
1118
- console.clear();
1119
- showError(`Error: ${e.message || error}`);
1120
- process.exit(1);
1121
1329
  }
1122
1330
  }
1123
- }
1124
- runApp();
1331
+ runApp();
1332
+ } // End of startInteractiveMode
1125
1333
  //# sourceMappingURL=pgp-tool.js.map