lpgp 0.5.0 → 0.6.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
@@ -2,21 +2,19 @@
2
2
  import * as openpgp from 'openpgp';
3
3
  import inquirer from 'inquirer';
4
4
  import { execSync } from 'child_process';
5
- import * as readline from 'readline';
6
- import { stdin as input, stdout as output } from 'process';
7
5
  import clipboardy from 'clipboardy';
8
6
  import { Command } from 'commander';
9
7
  import { Db } from './db.js';
10
8
  import { KeyManager } from './key-manager.js';
11
- import { extractPublicKeyInfo } from './key-utils.js';
9
+ import { extractPublicKeyInfo, formatMaskedRecipient } from './key-utils.js';
12
10
  import { escapeablePrompt, enableGlobalEscape, checkAndResetEscape, EscapeError, } from './prompts.js';
13
- import { getStoredPassphrase, storePassphrase, hasStoredPassphrase, } from './keychain.js';
14
- import { colors, icons, printBanner, printDivider, showSuccess, showError, showWarning, showLoading, promptMessage, mainMenuChoice, backChoice, exitChoice, } from './ui.js';
11
+ import { getCachedPassphrase, cachePassphrase, } from './passphrase-store.js';
12
+ import { readInlineMultiline } from './inline-editor.js';
13
+ import { colors, icons, printBanner, printDivider, printHomeStatus, showSuccess, showError, showWarning, showInfo, showLoading, promptMessage, mainMenuChoice, backChoice, exitChoice, } from './ui.js';
15
14
  import { generateCommand, exportPublicCommand, listKeysCommand, encryptCommand, decryptCommand, } from './cli-commands.js';
16
15
  import { readFileSync } from 'fs';
17
16
  import { dirname, join } from 'path';
18
17
  import { fileURLToPath } from 'url';
19
- // Get package version
20
18
  const __filename = fileURLToPath(import.meta.url);
21
19
  const __dirname = dirname(__filename);
22
20
  function getPackageVersion() {
@@ -29,17 +27,13 @@ function getPackageVersion() {
29
27
  return '0.0.0';
30
28
  }
31
29
  }
32
- // Helper: Collect multiple --to values
33
30
  function collect(value, previous) {
34
31
  return previous.concat([value]);
35
32
  }
36
- // Check if running in CLI mode (has subcommand arguments)
37
33
  function isCLIMode() {
38
- // If we have arguments beyond the script name that look like commands
39
34
  const args = process.argv.slice(2);
40
35
  if (args.length === 0)
41
36
  return false;
42
- // Check if first arg is a known command or starts with -
43
37
  const commands = [
44
38
  'generate',
45
39
  'export-public',
@@ -53,7 +47,6 @@ function isCLIMode() {
53
47
  ];
54
48
  return commands.some((cmd) => args[0] === cmd || args[0]?.startsWith('-'));
55
49
  }
56
- // Set up CLI commands
57
50
  function setupCLI() {
58
51
  const program = new Command()
59
52
  .name('lpgp')
@@ -94,148 +87,172 @@ function setupCLI() {
94
87
  .action(decryptCommand);
95
88
  program.parse();
96
89
  }
97
- // Run CLI mode if arguments provided
98
90
  if (isCLIMode()) {
99
91
  setupCLI();
100
92
  }
101
93
  else {
102
- // Interactive mode - continue with existing behavior
103
94
  startInteractiveMode();
104
95
  }
105
96
  function startInteractiveMode() {
106
- // Config to allow weak keys like DSA (not recommended for production)
107
97
  const weakKeyConfig = {
108
98
  rejectPublicKeyAlgorithms: new Set(),
109
99
  rejectHashAlgorithms: new Set(),
110
100
  rejectMessageHashAlgorithms: new Set(),
111
101
  rejectCurves: new Set(),
102
+ allowMissingKeyFlags: true,
112
103
  };
113
- // Database and key manager (initialized in main())
114
104
  let db;
115
105
  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() {
106
+ const unlockedKeys = new Map();
107
+ function checkEditorAvailable(command) {
120
108
  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;
109
+ execSync(`which ${command}`, { stdio: 'ignore' });
110
+ return true;
133
111
  }
134
112
  catch {
135
- return null;
113
+ return false;
136
114
  }
137
115
  }
138
- // Get latest version from npm registry
139
- function getLatestVersion() {
116
+ function detectAvailableEditors() {
117
+ const editors = [
118
+ { name: 'VS Code', command: 'code', available: false },
119
+ { name: 'Neovim', command: 'nvim', available: false },
120
+ { name: 'Vim', command: 'vim', available: false },
121
+ { name: 'Nano', command: 'nano', available: false },
122
+ { name: 'Emacs', command: 'emacs', available: false },
123
+ ];
124
+ if (process.platform === 'darwin') {
125
+ editors.push({ name: 'TextEdit', command: 'open -e', available: true });
126
+ }
127
+ else if (process.platform === 'win32') {
128
+ editors.push({ name: 'Notepad', command: 'notepad', available: true });
129
+ }
130
+ for (const editor of editors) {
131
+ if (editor.command.includes('open -e') || editor.command === 'notepad') {
132
+ editor.available = true;
133
+ }
134
+ else {
135
+ editor.available = checkEditorAvailable(editor.command);
136
+ }
137
+ }
138
+ return editors.filter((e) => e.available);
139
+ }
140
+ function getEditorInstructions(editorCommand) {
141
+ const instructions = {
142
+ nano: 'Save: Ctrl+O, then Enter. Exit: Ctrl+X',
143
+ vim: 'Save and exit: :wq | Cancel: :q!',
144
+ nvim: 'Save and exit: :wq | Cancel: :q!',
145
+ code: 'Save: Cmd/Ctrl+S, then close the editor tab',
146
+ emacs: 'Save: Ctrl+X Ctrl+S | Exit: Ctrl+X Ctrl+C',
147
+ 'open -e': 'Save: Cmd+S, then close the window',
148
+ notepad: 'Save: Ctrl+S, then close the window',
149
+ };
150
+ return instructions[editorCommand] || 'Save and close the editor when done';
151
+ }
152
+ // ---------- Clipboard / key extraction ----------
153
+ function extractAllPublicKeys(content) {
154
+ const keyRegex = /-----BEGIN PGP PUBLIC KEY BLOCK-----[\s\S]*?-----END PGP PUBLIC KEY BLOCK-----/g;
155
+ return content.match(keyRegex) ?? [];
156
+ }
157
+ async function readClipboardSafe() {
140
158
  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();
159
+ return await clipboardy.read();
146
160
  }
147
161
  catch {
148
162
  return null;
149
163
  }
150
164
  }
151
- // Detect which package manager to use
152
- function detectPackageManager() {
165
+ async function writeClipboardSafe(content) {
153
166
  try {
154
- execSync('which pnpm 2>/dev/null || where pnpm 2>nul', {
155
- stdio: ['pipe', 'pipe', 'pipe'],
156
- });
157
- return 'pnpm';
167
+ await clipboardy.write(content);
168
+ return true;
158
169
  }
159
170
  catch {
171
+ return false;
172
+ }
173
+ }
174
+ // ---------- Session: unlock keys ----------
175
+ async function unlockKeypair(keypair, options = {}) {
176
+ const existing = unlockedKeys.get(keypair.fingerprint);
177
+ if (existing)
178
+ return existing;
179
+ const privateKey = await openpgp.readPrivateKey({
180
+ armoredKey: keypair.private_key,
181
+ config: weakKeyConfig,
182
+ });
183
+ if (!keypair.passphrase_protected) {
184
+ unlockedKeys.set(keypair.fingerprint, privateKey);
185
+ return privateKey;
186
+ }
187
+ const cached = getCachedPassphrase(keypair.fingerprint);
188
+ if (cached) {
160
189
  try {
161
- execSync('which yarn 2>/dev/null || where yarn 2>nul', {
162
- stdio: ['pipe', 'pipe', 'pipe'],
190
+ const unlocked = await openpgp.decryptKey({
191
+ privateKey,
192
+ passphrase: cached,
193
+ config: weakKeyConfig,
163
194
  });
164
- return 'yarn';
195
+ unlockedKeys.set(keypair.fingerprint, unlocked);
196
+ return unlocked;
165
197
  }
166
198
  catch {
167
- return 'npm';
199
+ if (options.silent)
200
+ return null;
201
+ showWarning(`Saved passphrase for "${keypair.name}" is invalid.`);
168
202
  }
169
203
  }
170
- }
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;
204
+ else if (options.silent) {
205
+ return null;
180
206
  }
181
- return false;
182
- }
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!');
207
+ let attempts = 0;
208
+ while (attempts < 3) {
209
+ attempts++;
210
+ const { passphrase } = await escapeablePrompt([
211
+ {
212
+ type: 'password',
213
+ name: 'passphrase',
214
+ message: promptMessage(`Passphrase for "${keypair.name}":`),
215
+ mask: '*',
216
+ },
217
+ ]);
218
+ if (!passphrase) {
219
+ showWarning('Cancelled.');
220
+ return null;
200
221
  }
201
- else {
202
- showSuccess('lpgp installed globally! You can now run it with just "lpgp"');
222
+ try {
223
+ const unlocked = await openpgp.decryptKey({
224
+ privateKey,
225
+ passphrase,
226
+ config: weakKeyConfig,
227
+ });
228
+ cachePassphrase(keypair.fingerprint, passphrase);
229
+ unlockedKeys.set(keypair.fingerprint, unlocked);
230
+ showSuccess('Passphrase saved locally — you won\'t be asked again.');
231
+ return unlocked;
232
+ }
233
+ catch {
234
+ showError('Incorrect passphrase. Try again.');
203
235
  }
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
236
  }
237
+ return null;
214
238
  }
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 })));
223
- }
224
- else {
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.');
239
+ async function unlockAllCached() {
240
+ const keypairs = db.select({ table: 'keypair' });
241
+ for (const kp of keypairs) {
242
+ if (kp.passphrase_protected) {
243
+ await unlockKeypair(kp, { silent: true });
244
+ }
245
+ else {
246
+ await unlockKeypair(kp);
229
247
  }
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() });
238
248
  }
249
+ }
250
+ function getUnlockedPrivateKeys() {
251
+ return Array.from(unlockedKeys.values());
252
+ }
253
+ // ---------- Encryption ----------
254
+ async function encryptForKeys(message, publicKeysArmored) {
255
+ const publicKeys = await Promise.all(publicKeysArmored.map((key) => openpgp.readKey({ armoredKey: key, config: weakKeyConfig })));
239
256
  const encrypted = await openpgp.encrypt({
240
257
  message: await openpgp.createMessage({ text: message }),
241
258
  encryptionKeys: publicKeys,
@@ -243,1091 +260,774 @@ function startInteractiveMode() {
243
260
  });
244
261
  return encrypted;
245
262
  }
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,
340
- });
263
+ // ---------- Decryption ----------
264
+ async function decryptWithSession(encryptedMessage) {
341
265
  const message = await openpgp.readMessage({
342
266
  armoredMessage: encryptedMessage,
343
267
  });
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;
352
- }
353
- function checkEditorAvailable(command) {
354
- try {
355
- execSync(`which ${command}`, { stdio: 'ignore' });
356
- return true;
268
+ const tryWith = async (keys) => {
269
+ if (keys.length === 0)
270
+ return null;
271
+ try {
272
+ const { data } = await openpgp.decrypt({
273
+ message,
274
+ decryptionKeys: keys,
275
+ config: weakKeyConfig,
276
+ });
277
+ return data;
278
+ }
279
+ catch {
280
+ return null;
281
+ }
282
+ };
283
+ const firstTry = await tryWith(getUnlockedPrivateKeys());
284
+ if (firstTry !== null) {
285
+ markKeyAsUsed(message);
286
+ return firstTry;
357
287
  }
358
- catch {
359
- return false;
288
+ const keyIDs = message.getEncryptionKeyIDs();
289
+ if (keyIDs.length === 0) {
290
+ throw new Error('Message contains no recipient information');
360
291
  }
361
- }
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 });
292
+ const allKeypairs = db.select({ table: 'keypair' });
293
+ const matching = [];
294
+ const seen = new Set();
295
+ for (const keyID of keyIDs) {
296
+ const idHex = keyID.toHex().toUpperCase();
297
+ for (const kp of allKeypairs) {
298
+ if (seen.has(kp.id))
299
+ continue;
300
+ if (kp.fingerprint.toUpperCase().endsWith(idHex)) {
301
+ matching.push(kp);
302
+ seen.add(kp.id);
303
+ }
304
+ }
373
305
  }
374
- else if (process.platform === 'win32') {
375
- editors.push({ name: 'Notepad', command: 'notepad', available: true });
306
+ const locked = matching.filter((kp) => !unlockedKeys.has(kp.fingerprint));
307
+ if (locked.length === 0) {
308
+ throw new Error('This message was not encrypted for any of your keys');
376
309
  }
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
310
+ for (const kp of locked) {
311
+ showInfo(`Message is encrypted for "${kp.name}" — unlocking…`);
312
+ const unlocked = await unlockKeypair(kp);
313
+ if (!unlocked)
314
+ continue;
315
+ const result = await tryWith([unlocked]);
316
+ if (result !== null) {
317
+ markKeyAsUsed(message);
318
+ return result;
381
319
  }
382
- else {
383
- editor.available = checkEditorAvailable(editor.command);
320
+ }
321
+ throw new Error('Could not decrypt with any of your matching keys');
322
+ }
323
+ function markKeyAsUsed(message) {
324
+ const keyIDs = message.getEncryptionKeyIDs();
325
+ if (keyIDs.length === 0)
326
+ return;
327
+ const allKeypairs = db.select({ table: 'keypair' });
328
+ for (const keyID of keyIDs) {
329
+ const idHex = keyID.toHex().toUpperCase();
330
+ const match = allKeypairs.find((kp) => kp.fingerprint.toUpperCase().endsWith(idHex) &&
331
+ unlockedKeys.has(kp.fingerprint));
332
+ if (match) {
333
+ db.update('keypair', { key: 'id', value: match.id }, { last_used_at: new Date().toISOString() });
334
+ return;
384
335
  }
385
336
  }
386
- return editors.filter((e) => e.available);
387
337
  }
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);
338
+ // ---------- Auto-save contact (silent) ----------
339
+ async function autoSaveContact(publicKeyArmored) {
340
+ try {
341
+ const keyInfo = await extractPublicKeyInfo(publicKeyArmored);
342
+ if (!keyInfo.name || keyInfo.name === 'Unknown')
343
+ return;
344
+ if (!keyInfo.email || keyInfo.email === 'unknown@example.com')
345
+ return;
346
+ const existing = db.select({
347
+ table: 'contact',
348
+ where: {
349
+ key: 'fingerprint',
350
+ compare: 'is',
351
+ value: keyInfo.fingerprint,
352
+ },
397
353
  });
398
- rl.on('close', () => {
399
- resolve(lines.join('\n'));
354
+ if (existing.length > 0)
355
+ return;
356
+ const ownKey = db.select({
357
+ table: 'keypair',
358
+ where: {
359
+ key: 'fingerprint',
360
+ compare: 'is',
361
+ value: keyInfo.fingerprint,
362
+ },
400
363
  });
401
- });
402
- }
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();
364
+ if (ownKey.length > 0)
365
+ return;
366
+ db.insert('contact', {
367
+ name: keyInfo.name,
368
+ email: keyInfo.email,
369
+ fingerprint: keyInfo.fingerprint,
370
+ public_key: publicKeyArmored,
371
+ algorithm: keyInfo.algorithm,
372
+ key_size: keyInfo.keySize,
373
+ trusted: false,
374
+ last_verified_at: null,
375
+ notes: null,
376
+ expires_at: keyInfo.expiresAt,
377
+ revoked: false,
378
+ });
379
+ showInfo(`Saved "${keyInfo.name}" to contacts.`);
412
380
  }
413
381
  catch {
414
- showWarning('Could not access clipboard');
415
- return 0;
416
- }
417
- const keys = extractAllPublicKeys(clipboardContent);
418
- if (keys.length === 0) {
419
- showWarning('No public keys found in clipboard');
420
- return 0;
382
+ // Silently skip on any failure
421
383
  }
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}`);
384
+ }
385
+ async function chooseInputMethod(promptText, skipClipboard = false) {
386
+ const availableEditors = detectAvailableEditors();
387
+ while (true) {
388
+ const inputChoices = [];
389
+ if (!skipClipboard) {
390
+ inputChoices.push({
391
+ name: `${icons.clipboard} Paste from clipboard`,
392
+ value: 'clipboard',
393
+ });
394
+ }
395
+ if (availableEditors.length > 0) {
396
+ inputChoices.push({
397
+ name: `${icons.editor} Open in an editor`,
398
+ value: 'editor',
399
+ });
400
+ }
401
+ inputChoices.push({
402
+ name: `${icons.inline} Type inline ${colors.muted('(Ctrl+D when done)')}`,
403
+ value: 'inline',
404
+ });
405
+ const choices = [
406
+ ...inputChoices,
407
+ new inquirer.Separator(),
408
+ mainMenuChoice(),
409
+ ];
410
+ const { inputMethod } = await escapeablePrompt([
411
+ {
412
+ type: 'list',
413
+ name: 'inputMethod',
414
+ message: promptMessage(promptText),
415
+ choices,
416
+ },
417
+ ]);
418
+ if (inputMethod === 'main-menu')
419
+ return { cancelled: true };
420
+ if (inputMethod === 'clipboard') {
421
+ const content = await readClipboardSafe();
422
+ if (!content || content.trim() === '') {
423
+ showError('Clipboard is empty.');
433
424
  continue;
434
425
  }
435
- recipients.push({
436
- name: recipientName,
437
- publicKey,
438
- });
439
- showSuccess(`Added recipient: ${recipientName}`);
440
- addedCount++;
426
+ return { value: content };
441
427
  }
442
- catch (error) {
443
- showError(`Failed to parse a key: ${error instanceof Error ? error.message : 'unknown error'}`);
428
+ if (inputMethod === 'editor') {
429
+ const editorChoices = availableEditors.map((e) => ({
430
+ name: `${icons.editor} ${e.name} ${colors.muted(`(${getEditorInstructions(e.command)})`)}`,
431
+ value: e.command,
432
+ }));
433
+ editorChoices.push(new inquirer.Separator(), backChoice(), mainMenuChoice());
434
+ const { selectedEditor } = await escapeablePrompt([
435
+ {
436
+ type: 'list',
437
+ name: 'selectedEditor',
438
+ message: promptMessage('Choose your editor:'),
439
+ choices: editorChoices,
440
+ },
441
+ ]);
442
+ if (selectedEditor === 'back')
443
+ continue;
444
+ if (selectedEditor === 'main-menu')
445
+ return { cancelled: true };
446
+ const originalEditor = process.env.EDITOR;
447
+ const originalVisual = process.env.VISUAL;
448
+ process.env.EDITOR = selectedEditor;
449
+ process.env.VISUAL = selectedEditor;
450
+ try {
451
+ const { editorInput } = await escapeablePrompt([
452
+ {
453
+ type: 'editor',
454
+ name: 'editorInput',
455
+ message: promptMessage('Press Enter to open editor:'),
456
+ postfix: '.txt',
457
+ waitForUseInput: false,
458
+ },
459
+ ]);
460
+ return { value: editorInput ?? '' };
461
+ }
462
+ finally {
463
+ if (originalEditor !== undefined)
464
+ process.env.EDITOR = originalEditor;
465
+ else
466
+ delete process.env.EDITOR;
467
+ if (originalVisual !== undefined)
468
+ process.env.VISUAL = originalVisual;
469
+ else
470
+ delete process.env.VISUAL;
471
+ }
472
+ }
473
+ if (inputMethod === 'inline') {
474
+ try {
475
+ const value = await readInlineMultiline('Type your message:');
476
+ return { value };
477
+ }
478
+ catch (error) {
479
+ if (error instanceof EscapeError)
480
+ return { cancelled: true };
481
+ throw error;
482
+ }
444
483
  }
445
484
  }
446
- return addedCount;
485
+ }
486
+ async function getRecipientFromPaste() {
487
+ let value;
488
+ try {
489
+ value = await readInlineMultiline("Paste the recipient's PGP PUBLIC KEY block:", '(Paste, then press Ctrl+D)');
490
+ }
491
+ catch (error) {
492
+ if (error instanceof EscapeError)
493
+ return null;
494
+ throw error;
495
+ }
496
+ if (!value.includes('BEGIN PGP PUBLIC KEY BLOCK')) {
497
+ showError('No PGP public key found in the input.');
498
+ return null;
499
+ }
500
+ const keys = extractAllPublicKeys(value);
501
+ if (keys.length === 0) {
502
+ showError('No valid PGP public key block found.');
503
+ return null;
504
+ }
505
+ try {
506
+ const armored = keys[0];
507
+ await openpgp.readKey({ armoredKey: armored, config: weakKeyConfig });
508
+ const info = await extractPublicKeyInfo(armored);
509
+ const name = formatMaskedRecipient(info);
510
+ return { name, publicKey: armored, isNew: true };
511
+ }
512
+ catch (error) {
513
+ showError(`Failed to read public key: ${error instanceof Error ? error.message : error}`);
514
+ return null;
515
+ }
447
516
  }
448
517
  async function selectMultipleRecipients() {
449
518
  const recipients = [];
450
519
  const contacts = db.select({ table: 'contact' });
451
- const defaultKeypair = await keyManager.getDefaultKeypair();
452
- // Build the menu choices
453
- function buildChoices() {
520
+ const defaultKeypair = keyManager.getDefaultKeypair();
521
+ while (true) {
454
522
  const choices = [];
455
- // Show current recipients count
456
523
  if (recipients.length > 0) {
457
524
  choices.push({
458
525
  name: colors.primary(`── Current recipients: ${recipients.length} ──`),
459
- value: 'show-recipients',
526
+ value: 'show',
460
527
  });
461
528
  }
462
- // Option to add self (if not already added)
463
- const selfAdded = recipients.some((r) => r.name === 'Myself');
464
- if (defaultKeypair && !selfAdded) {
529
+ if (defaultKeypair && !recipients.some((r) => r.name === 'Myself')) {
465
530
  choices.push({
466
- name: `${icons.key} Add myself ${colors.muted('(so I can also decrypt)')}`,
531
+ name: `${icons.key} Add myself ${colors.muted('(so you can decrypt too)')}`,
467
532
  value: 'self',
468
533
  });
469
534
  }
470
- // Option to select from contacts
471
535
  if (contacts.length > 0) {
472
536
  choices.push({
473
- name: `${icons.contact} Select from saved contacts ${colors.muted(`(${contacts.length} available)`)}`,
537
+ name: `${icons.contact} Pick from contacts ${colors.muted(`(${contacts.length})`)}`,
474
538
  value: 'contacts',
475
539
  });
476
540
  }
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({
541
+ choices.push({ name: `${icons.clipboard} Add from clipboard`, value: 'clipboard' }, { name: `${icons.inline} Paste a public key`, value: 'paste' }, new inquirer.Separator(), {
488
542
  name: recipients.length > 0
489
- ? `${icons.success} Done adding recipients`
543
+ ? `${icons.success} Done`
490
544
  : `${icons.back} Cancel`,
491
545
  value: 'done',
492
546
  });
493
- return choices;
494
- }
495
- let addMore = true;
496
- while (addMore) {
497
- const { addMethod } = await escapeablePrompt([
547
+ const { method } = await escapeablePrompt([
498
548
  {
499
549
  type: 'list',
500
- name: 'addMethod',
550
+ name: 'method',
501
551
  message: promptMessage('Add recipients:'),
502
- choices: buildChoices(),
552
+ choices,
503
553
  },
504
554
  ]);
505
- if (addMethod === 'done') {
506
- addMore = false;
507
- }
508
- else if (addMethod === 'show-recipients') {
509
- // Show current recipients
555
+ if (method === 'done')
556
+ break;
557
+ if (method === 'show') {
510
558
  console.log(colors.primary('\nCurrent recipients:'));
511
- for (const r of recipients) {
559
+ for (const r of recipients)
512
560
  console.log(colors.muted(` • ${r.name}`));
513
- }
514
561
  console.log();
562
+ continue;
515
563
  }
516
- else if (addMethod === 'self') {
564
+ if (method === 'self') {
517
565
  if (defaultKeypair) {
518
566
  recipients.push({
519
567
  name: 'Myself',
520
568
  publicKey: defaultKeypair.public_key,
569
+ isNew: false,
521
570
  });
522
- showSuccess('Added yourself as a recipient');
571
+ showSuccess('Added yourself');
523
572
  }
573
+ continue;
524
574
  }
525
- else if (addMethod === 'contacts') {
526
- // Show contacts as a checkbox
527
- const { selectedContacts } = await escapeablePrompt([
575
+ if (method === 'contacts') {
576
+ const { selected } = await escapeablePrompt([
528
577
  {
529
578
  type: 'checkbox',
530
- name: 'selectedContacts',
531
- message: promptMessage('Select contacts (space to toggle, enter to confirm):'),
579
+ name: 'selected',
580
+ message: promptMessage('Select contacts:'),
532
581
  choices: contacts.map((c) => {
533
- const alreadyAdded = recipients.some((r) => r.publicKey === c.public_key);
582
+ const already = recipients.some((r) => r.publicKey === c.public_key);
534
583
  return {
535
- name: `${c.name} <${c.email || 'no email'}>${alreadyAdded ? colors.muted(' (already added)') : ''}`,
584
+ name: `${c.name} <${c.email}>${already ? colors.muted(' (added)') : ''}`,
536
585
  value: c.id,
537
- checked: false,
538
- disabled: alreadyAdded,
586
+ disabled: already,
539
587
  };
540
588
  }),
541
589
  },
542
590
  ]);
543
- let addedCount = 0;
544
- for (const contactId of selectedContacts) {
545
- const contact = contacts.find((c) => c.id === contactId);
546
- if (contact) {
591
+ for (const id of selected) {
592
+ const c = contacts.find((x) => x.id === id);
593
+ if (c) {
547
594
  recipients.push({
548
- name: `${contact.name} <${contact.email || 'no email'}>`,
549
- publicKey: contact.public_key,
595
+ name: formatMaskedRecipient({
596
+ name: c.name,
597
+ email: c.email,
598
+ fingerprint: c.fingerprint,
599
+ }),
600
+ publicKey: c.public_key,
601
+ isNew: false,
550
602
  });
551
- addedCount++;
552
603
  }
553
604
  }
554
- if (addedCount > 0) {
555
- showSuccess(`Added ${addedCount} contact${addedCount > 1 ? 's' : ''}`);
556
- }
605
+ continue;
557
606
  }
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();
607
+ if (method === 'clipboard') {
608
+ const content = await readClipboardSafe();
609
+ const keys = content ? extractAllPublicKeys(content) : [];
610
+ if (keys.length === 0) {
611
+ showWarning('No public keys found in clipboard.');
612
+ continue;
564
613
  }
565
- }
566
- else if (addMethod === 'manual') {
567
- const publicKey = await getRecipientPublicKey();
568
- if (publicKey) {
614
+ for (const armored of keys) {
569
615
  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}`);
616
+ await openpgp.readKey({ armoredKey: armored, config: weakKeyConfig });
617
+ const info = await extractPublicKeyInfo(armored);
618
+ const name = formatMaskedRecipient(info);
619
+ if (recipients.some((r) => r.publicKey === armored)) {
620
+ showWarning(`Skipping duplicate: ${name}`);
621
+ continue;
583
622
  }
623
+ recipients.push({ name, publicKey: armored, isNew: true });
624
+ showSuccess(`Added ${name}`);
584
625
  }
585
626
  catch (error) {
586
- showError('Failed to parse public key');
627
+ showError(`Skipped invalid key: ${error}`);
628
+ }
629
+ }
630
+ continue;
631
+ }
632
+ if (method === 'paste') {
633
+ const r = await getRecipientFromPaste();
634
+ if (r) {
635
+ if (recipients.some((x) => x.publicKey === r.publicKey)) {
636
+ showWarning('Already added.');
637
+ }
638
+ else {
639
+ recipients.push(r);
640
+ showSuccess(`Added ${r.name}`);
587
641
  }
588
642
  }
589
643
  }
590
644
  }
591
645
  return recipients;
592
646
  }
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');
647
+ // ---------- Actions ----------
648
+ async function actionCopyPublicKey() {
649
+ const keypairs = db.select({ table: 'keypair' });
650
+ if (keypairs.length === 0) {
651
+ showError('No keypairs found. Create one from the key management menu.');
652
+ await pause();
653
+ return;
600
654
  }
601
- catch (e) {
602
- // Clipboard not available, continue without it
655
+ let selected;
656
+ if (keypairs.length === 1) {
657
+ selected = keypairs[0];
603
658
  }
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([
659
+ else {
660
+ const defaultId = keypairs.find((kp) => kp.is_default)?.id;
661
+ const { keypairId } = await escapeablePrompt([
608
662
  {
609
- type: 'confirm',
610
- name: 'useClipboard',
611
- message: 'Public key detected in clipboard. Use it?',
612
- default: true,
663
+ type: 'list',
664
+ name: 'keypairId',
665
+ message: promptMessage('Which public key would you like to copy?'),
666
+ default: defaultId,
667
+ choices: [
668
+ ...keypairs.map((kp) => ({
669
+ name: `${icons.key} ${kp.name}${kp.is_default ? ` ${colors.muted('(default)')}` : ''}`,
670
+ value: kp.id,
671
+ })),
672
+ new inquirer.Separator(),
673
+ mainMenuChoice(),
674
+ ],
613
675
  },
614
676
  ]);
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];
677
+ if (keypairId === 'main-menu')
678
+ return;
679
+ const found = keypairs.find((kp) => kp.id === keypairId);
680
+ if (!found)
681
+ return;
682
+ selected = found;
683
+ }
684
+ const ok = await writeClipboardSafe(selected.public_key);
685
+ console.log();
686
+ if (ok) {
687
+ showSuccess(`Public key for "${selected.name}" copied to clipboard.`);
688
+ console.log(colors.muted(` Fingerprint: ${selected.fingerprint.slice(-16)}`));
689
+ }
690
+ else {
691
+ showError('Could not write to clipboard.');
692
+ }
693
+ console.log();
694
+ await pause();
695
+ }
696
+ async function actionEncrypt() {
697
+ let recipients = [];
698
+ // 1. Auto-detect a public key in clipboard
699
+ const clipboard = await readClipboardSafe();
700
+ const clipboardKeys = clipboard ? extractAllPublicKeys(clipboard) : [];
701
+ if (clipboardKeys.length === 1) {
702
+ const armored = clipboardKeys[0];
703
+ try {
704
+ await openpgp.readKey({ armoredKey: armored, config: weakKeyConfig });
705
+ const info = await extractPublicKeyInfo(armored);
706
+ const masked = formatMaskedRecipient(info);
707
+ const { useClipboard } = await escapeablePrompt([
708
+ {
709
+ type: 'confirm',
710
+ name: 'useClipboard',
711
+ message: promptMessage(`Encrypt for ${colors.successBold(masked)}?`),
712
+ default: true,
713
+ },
714
+ ]);
715
+ if (useClipboard) {
716
+ recipients = [{ name: masked, publicKey: armored, isNew: true }];
619
717
  }
620
718
  }
719
+ catch {
720
+ // Bad key, fall through to picker
721
+ }
621
722
  }
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'));
723
+ // 2. If no clipboard recipient, show picker
724
+ if (recipients.length === 0) {
725
+ const contacts = db.select({ table: 'contact' });
726
+ const defaultKeypair = keyManager.getDefaultKeypair();
727
+ const choices = [];
728
+ if (contacts.length > 0) {
729
+ choices.push({
730
+ name: `${icons.contact} A saved contact ${colors.muted(`(${contacts.length})`)}`,
731
+ value: 'contact',
643
732
  });
733
+ }
734
+ choices.push({
735
+ name: `${icons.inline} Paste a public key`,
736
+ value: 'paste',
737
+ });
738
+ choices.push({
739
+ name: `${icons.multiple} Multiple recipients`,
740
+ value: 'multi',
644
741
  });
742
+ if (defaultKeypair) {
743
+ choices.push({
744
+ name: `${icons.key} Myself`,
745
+ value: 'self',
746
+ });
747
+ }
748
+ choices.push(new inquirer.Separator(), mainMenuChoice());
749
+ const { recipient } = await escapeablePrompt([
750
+ {
751
+ type: 'list',
752
+ name: 'recipient',
753
+ message: promptMessage('Who do you want to encrypt for?'),
754
+ choices,
755
+ },
756
+ ]);
757
+ if (recipient === 'main-menu')
758
+ return;
759
+ if (recipient === 'contact') {
760
+ const { contactId } = await escapeablePrompt([
761
+ {
762
+ type: 'list',
763
+ name: 'contactId',
764
+ message: promptMessage('Select contact:'),
765
+ choices: [
766
+ ...contacts.map((c) => ({
767
+ name: `${icons.contact} ${c.name} ${colors.muted(`<${c.email}>`)}`,
768
+ value: c.id,
769
+ })),
770
+ new inquirer.Separator(),
771
+ backChoice(),
772
+ ],
773
+ },
774
+ ]);
775
+ if (contactId === 'back')
776
+ return actionEncrypt();
777
+ const c = contacts.find((x) => x.id === contactId);
778
+ if (!c)
779
+ return;
780
+ recipients = [
781
+ {
782
+ name: formatMaskedRecipient({
783
+ name: c.name,
784
+ email: c.email,
785
+ fingerprint: c.fingerprint,
786
+ }),
787
+ publicKey: c.public_key,
788
+ isNew: false,
789
+ },
790
+ ];
791
+ }
792
+ else if (recipient === 'paste') {
793
+ const r = await getRecipientFromPaste();
794
+ if (!r)
795
+ return;
796
+ recipients = [r];
797
+ }
798
+ else if (recipient === 'multi') {
799
+ recipients = await selectMultipleRecipients();
800
+ if (recipients.length === 0)
801
+ return;
802
+ }
803
+ else if (recipient === 'self') {
804
+ if (defaultKeypair) {
805
+ recipients = [
806
+ {
807
+ name: 'Myself',
808
+ publicKey: defaultKeypair.public_key,
809
+ isNew: false,
810
+ },
811
+ ];
812
+ }
813
+ }
645
814
  }
646
- // Validate public key format
647
- if (!publicKey.includes('BEGIN PGP PUBLIC KEY BLOCK')) {
648
- console.log();
649
- showError('Invalid public key format');
815
+ if (recipients.length === 0)
816
+ return;
817
+ if (recipients.length > 1) {
818
+ console.log(colors.primary('\nEncrypting for:'));
819
+ for (const r of recipients)
820
+ console.log(colors.muted(` • ${r.name}`));
650
821
  console.log();
651
- return null;
652
822
  }
653
- // Try to read the key to validate it
823
+ // 3. Get message
824
+ const result = await chooseInputMethod('How would you like to enter your message?');
825
+ if ('cancelled' in result)
826
+ return;
827
+ const message = result.value;
828
+ if (!message || message.trim() === '') {
829
+ showError('No message provided.');
830
+ await pause();
831
+ return;
832
+ }
833
+ // 4. Encrypt
834
+ showLoading('Encrypting…');
835
+ const encrypted = await encryptForKeys(message, recipients.map((r) => r.publicKey));
836
+ // 5. Display + copy
837
+ console.clear();
838
+ printBanner();
839
+ console.log(colors.successBold('Encrypted Message:\n'));
840
+ printDivider();
841
+ console.log(encrypted);
842
+ printDivider();
843
+ console.log();
844
+ const copied = await writeClipboardSafe(encrypted);
845
+ if (copied) {
846
+ showSuccess('Encrypted message copied to clipboard.');
847
+ }
848
+ else {
849
+ showWarning('Clipboard unavailable.');
850
+ }
851
+ // 6. Auto-save new contacts (silent if name available)
852
+ for (const r of recipients) {
853
+ if (r.isNew) {
854
+ await autoSaveContact(r.publicKey);
855
+ }
856
+ }
857
+ console.log();
858
+ await pause();
859
+ }
860
+ async function actionDecrypt() {
861
+ let encrypted = null;
862
+ // 1. Auto-detect clipboard
863
+ const clipboard = await readClipboardSafe();
864
+ if (clipboard && clipboard.includes('BEGIN PGP MESSAGE')) {
865
+ const { useClipboard } = await escapeablePrompt([
866
+ {
867
+ type: 'confirm',
868
+ name: 'useClipboard',
869
+ message: promptMessage('Encrypted message detected in clipboard. Use it?'),
870
+ default: true,
871
+ },
872
+ ]);
873
+ if (useClipboard)
874
+ encrypted = clipboard;
875
+ }
876
+ // 2. Fall back to input method picker
877
+ if (!encrypted) {
878
+ const result = await chooseInputMethod('How would you like to enter the encrypted message?');
879
+ if ('cancelled' in result)
880
+ return;
881
+ encrypted = result.value;
882
+ }
883
+ if (!encrypted || !encrypted.includes('BEGIN PGP MESSAGE')) {
884
+ showError('No PGP message found.');
885
+ await pause();
886
+ return;
887
+ }
888
+ // 3. Decrypt
889
+ showLoading('Decrypting…');
654
890
  try {
655
- await openpgp.readKey({ armoredKey: publicKey, config: weakKeyConfig });
891
+ const plaintext = await decryptWithSession(encrypted);
892
+ console.clear();
893
+ printBanner();
894
+ console.log(colors.successBold('Decrypted Message:\n'));
895
+ printDivider();
896
+ console.log(plaintext);
897
+ printDivider();
656
898
  console.log();
657
- showSuccess('Valid public key');
899
+ const copied = await writeClipboardSafe(plaintext);
900
+ if (copied)
901
+ showSuccess('Decrypted message copied to clipboard.');
658
902
  console.log();
659
- return publicKey;
903
+ await pause();
660
904
  }
661
905
  catch (error) {
906
+ if (error instanceof EscapeError)
907
+ throw error;
908
+ const msg = error instanceof Error ? error.message : String(error);
662
909
  console.log();
663
- showError(`Failed to read public key: ${error instanceof Error ? error.message : error}`);
664
- return null;
910
+ showError(`Decryption failed: ${msg}`);
911
+ console.log();
912
+ await pause();
665
913
  }
666
914
  }
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';
679
- }
680
- function clearPassphraseCache() {
681
- // Clear all cached passphrases from memory
682
- passphraseCache.clear();
915
+ async function pause() {
916
+ await escapeablePrompt([
917
+ {
918
+ type: 'input',
919
+ name: 'continue',
920
+ message: colors.muted('Press Enter to continue…'),
921
+ },
922
+ ]);
683
923
  }
684
- async function main() {
685
- // Initialize database on first run
686
- if (!db) {
687
- db = await Db.init();
688
- keyManager = new KeyManager(db);
689
- }
924
+ // ---------- Main menu ----------
925
+ async function showMainMenu() {
690
926
  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
927
+ const defaultKp = keyManager.getDefaultKeypair();
928
+ printHomeStatus(defaultKp ? `${defaultKp.name} key` : null);
703
929
  const menuChoices = [
704
- { name: `${icons.encrypt} Encrypt a message`, value: 'encrypt' },
930
+ { name: `${icons.clipboard} Copy my public key`, value: 'copy' },
705
931
  { name: `${icons.decrypt} Decrypt a message`, value: 'decrypt' },
706
- { name: `${icons.key} Manage keys`, value: 'keys' },
932
+ { name: `${icons.encrypt} Encrypt a message`, value: 'encrypt' },
933
+ new inquirer.Separator(colors.muted(' ─────────')),
934
+ { name: `${icons.key} Manage keys & contacts`, value: 'keys' },
935
+ exitChoice(),
707
936
  ];
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
- }
728
- menuChoices.push(new inquirer.Separator());
729
- menuChoices.push(exitChoice());
730
937
  const { action } = await escapeablePrompt([
731
938
  {
732
939
  type: 'list',
733
940
  name: 'action',
734
- message: promptMessage('What would you like to do?'),
941
+ message: promptMessage('Choose an action'),
735
942
  choices: menuChoices,
736
943
  },
737
944
  ]);
738
945
  if (action === 'exit') {
739
- clearPassphraseCache();
946
+ clearSession();
740
947
  console.clear();
741
948
  process.exit(0);
742
949
  }
743
- if (action === 'install' || action === 'update') {
744
- await installOrUpdateGlobally(action === 'update');
745
- await escapeablePrompt([
746
- {
747
- type: 'input',
748
- name: 'continue',
749
- message: promptMessage('Press Enter to continue...'),
750
- },
751
- ]);
752
- return main();
753
- }
754
950
  if (action === 'keys') {
755
951
  await keyManager.showKeyManagementMenu();
756
- return main();
952
+ return;
953
+ }
954
+ if (action === 'copy') {
955
+ await actionCopyPublicKey();
956
+ return;
757
957
  }
758
958
  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') {
785
- return main();
786
- }
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();
797
- return main();
798
- }
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([
823
- {
824
- type: 'list',
825
- name: 'recipientSource',
826
- message: promptMessage('How would you like to specify the recipient?'),
827
- choices: recipientChoices,
828
- },
829
- ]);
830
- if (recipientSource === 'main' || recipientSource === 'main-menu') {
831
- return main();
832
- }
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
- }
861
- }
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;
872
- break recipientLoop;
873
- }
874
- }
875
- }
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
884
- inputChoices.push({
885
- name: `${icons.clipboard} Paste from clipboard`,
886
- value: 'clipboard',
887
- });
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
- });
893
- }
894
- else {
895
- inputChoices.push({
896
- name: `${icons.inline} Type inline ${colors.muted('(Enter, then Ctrl+D to finish)')}`,
897
- value: 'inline',
898
- });
899
- }
900
- // Add main menu option
901
- inputChoices.push(new inquirer.Separator(), mainMenuChoice());
902
- const { inputMethod } = await escapeablePrompt([
903
- {
904
- type: 'list',
905
- name: 'inputMethod',
906
- message: promptMessage('How would you like to enter your message?'),
907
- choices: inputChoices,
908
- },
909
- ]);
910
- if (inputMethod === 'back' || inputMethod === 'main-menu') {
911
- return main();
912
- }
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([
941
- {
942
- type: 'list',
943
- name: 'selectedEditor',
944
- message: promptMessage('Choose your editor:'),
945
- choices: editorChoices,
946
- },
947
- ]);
948
- if (selectedEditor === 'back') {
949
- // Re-ask for input method
950
- continue inputMethodLoop;
951
- }
952
- if (selectedEditor === 'main-menu') {
953
- return main();
954
- }
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;
975
- }
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
- }
990
- }
991
- }
992
- else {
993
- message = await readInlineMultilineInput('Enter your message:');
994
- break inputMethodLoop;
995
- }
996
- }
997
- if (!message || message.trim() === '') {
998
- console.log();
999
- showError('No message provided. Aborting.');
1000
- console.log();
1001
- return main();
1002
- }
1003
- console.log();
1004
- showLoading('Encrypting message...');
1005
- console.log();
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
- },
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) {
1090
- console.log();
1091
- showError(`Failed to save contact: ${error instanceof Error ? error.message : error}`);
1092
- }
1093
- }
1094
- }
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
- }
959
+ await actionEncrypt();
960
+ return;
1103
961
  }
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
1114
- inputChoices.push({
1115
- name: `${icons.clipboard} Paste from clipboard`,
1116
- value: 'clipboard',
1117
- });
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
- });
1123
- }
1124
- else {
1125
- inputChoices.push({
1126
- name: `${icons.inline} Type inline ${colors.muted('(Enter, then Ctrl+D to finish)')}`,
1127
- value: 'inline',
1128
- });
1129
- }
1130
- // Add main menu option
1131
- inputChoices.push(new inquirer.Separator(), mainMenuChoice());
1132
- const { inputMethod } = await escapeablePrompt([
1133
- {
1134
- type: 'list',
1135
- name: 'inputMethod',
1136
- message: promptMessage('How would you like to enter the encrypted message?'),
1137
- choices: inputChoices,
1138
- },
1139
- ]);
1140
- if (inputMethod === 'back' || inputMethod === 'main-menu') {
1141
- return main();
1142
- }
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([
1171
- {
1172
- type: 'list',
1173
- name: 'selectedEditor',
1174
- message: promptMessage('Choose your editor:'),
1175
- choices: editorChoices,
1176
- },
1177
- ]);
1178
- if (selectedEditor === 'back') {
1179
- // Re-ask for input method
1180
- continue decryptInputLoop;
1181
- }
1182
- if (selectedEditor === 'main-menu') {
1183
- return main();
1184
- }
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;
1205
- }
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
- }
1220
- }
1221
- }
1222
- else {
1223
- encrypted = await readInlineMultilineInput('Paste the encrypted message:');
1224
- break decryptInputLoop;
1225
- }
1226
- }
1227
- if (!encrypted || encrypted.trim() === '') {
1228
- console.log();
1229
- showError('No encrypted message provided. Aborting.');
1230
- console.log();
1231
- return main();
1232
- }
1233
- console.log();
1234
- showLoading('Decrypting message...');
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
- ]);
1264
- }
1265
- catch (error) {
1266
- // Re-throw escape errors to be handled by the main loop
1267
- if (error instanceof EscapeError)
1268
- throw error;
1269
- console.log();
1270
- showError(`Decryption failed: ${error instanceof Error ? error.message : error}`);
1271
- }
962
+ if (action === 'decrypt') {
963
+ await actionDecrypt();
964
+ return;
1272
965
  }
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();
966
+ }
967
+ function clearSession() {
968
+ unlockedKeys.clear();
969
+ }
970
+ // ---------- Bootstrap ----------
971
+ async function main() {
972
+ if (!db) {
973
+ db = await Db.init();
974
+ keyManager = new KeyManager(db);
1290
975
  }
1291
- else {
1292
- clearPassphraseCache();
1293
- console.clear();
976
+ printBanner();
977
+ const hasKeypair = keyManager.hasDefaultKeypair();
978
+ if (!hasKeypair) {
979
+ console.log();
980
+ showWarning("No keypair found. Let's set up your first keypair.");
981
+ console.log();
982
+ await keyManager.setupFirstKeypair();
983
+ console.log();
984
+ showSuccess('Setup complete!');
985
+ console.log();
1294
986
  }
1295
- }
1296
- // Graceful exit on Ctrl+C
1297
- process.on('SIGINT', () => {
1298
- clearPassphraseCache();
1299
- console.clear();
1300
- process.exit(0);
1301
- });
1302
- // Enable global escape key handling and run menu in a loop
1303
- enableGlobalEscape();
1304
- async function runApp() {
987
+ // Unlock default key up front if not already cached
988
+ const defaultKp = keyManager.getDefaultKeypair();
989
+ if (defaultKp &&
990
+ defaultKp.passphrase_protected &&
991
+ !unlockedKeys.has(defaultKp.fingerprint)) {
992
+ const unlocked = await unlockKeypair(defaultKp);
993
+ if (!unlocked) {
994
+ showWarning('No passphrase provided. Decryption will be unavailable.');
995
+ console.log();
996
+ }
997
+ }
998
+ // Try to unlock everything else from cache (silent)
999
+ await unlockAllCached();
1305
1000
  while (true) {
1306
1001
  try {
1307
- await main();
1002
+ await showMainMenu();
1308
1003
  }
1309
1004
  catch (error) {
1310
1005
  const e = error;
1311
- // If escape was pressed, just restart the menu
1312
1006
  if (error instanceof EscapeError ||
1313
1007
  checkAndResetEscape() ||
1314
1008
  e.message?.includes('prompt was closed')) {
1315
1009
  continue;
1316
1010
  }
1317
- // Handle Ctrl+C gracefully (inquirer throws ExitPromptError)
1318
1011
  if (e.message?.includes('force closed the prompt')) {
1319
- clearPassphraseCache();
1012
+ clearSession();
1320
1013
  console.clear();
1321
1014
  process.exit(0);
1322
1015
  }
1323
- // Handle other errors
1324
- clearPassphraseCache();
1325
- console.clear();
1016
+ clearSession();
1326
1017
  showError(`Error: ${e.message || error}`);
1327
- process.exit(1);
1018
+ await pause();
1328
1019
  }
1329
1020
  }
1330
1021
  }
1331
- runApp();
1332
- } // End of startInteractiveMode
1022
+ process.on('SIGINT', () => {
1023
+ clearSession();
1024
+ console.clear();
1025
+ process.exit(0);
1026
+ });
1027
+ enableGlobalEscape();
1028
+ main().catch((error) => {
1029
+ showError(`Fatal: ${error instanceof Error ? error.message : error}`);
1030
+ process.exit(1);
1031
+ });
1032
+ }
1333
1033
  //# sourceMappingURL=pgp-tool.js.map