lpgp 0.5.1 → 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,1110 +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);
263
+ // ---------- Decryption ----------
264
+ async function decryptWithSession(encryptedMessage) {
265
+ const message = await openpgp.readMessage({
266
+ armoredMessage: encryptedMessage,
267
+ });
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;
257
278
  }
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
- }
279
+ catch {
280
+ return null;
281
+ }
282
+ };
283
+ const firstTry = await tryWith(getUnlockedPrivateKeys());
284
+ if (firstTry !== null) {
285
+ markKeyAsUsed(message);
286
+ return firstTry;
287
+ }
288
+ const keyIDs = message.getEncryptionKeyIDs();
289
+ if (keyIDs.length === 0) {
290
+ throw new Error('Message contains no recipient information');
291
+ }
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);
330
303
  }
331
304
  }
332
305
  }
333
- // Only decrypt the key if it's passphrase-protected
334
- let privateKey;
335
- if (defaultKeypair.passphrase_protected) {
336
- privateKey = await openpgp.decryptKey({
337
- privateKey: await openpgp.readPrivateKey({
338
- armoredKey: defaultKeypair.private_key,
339
- config: weakKeyConfig,
340
- }),
341
- passphrase,
342
- config: weakKeyConfig,
343
- });
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');
344
309
  }
345
- else {
346
- privateKey = await openpgp.readPrivateKey({
347
- armoredKey: defaultKeypair.private_key,
348
- config: weakKeyConfig,
349
- });
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;
319
+ }
350
320
  }
351
- const message = await openpgp.readMessage({
352
- armoredMessage: encryptedMessage,
353
- });
354
- const { data: decrypted } = await openpgp.decrypt({
355
- message,
356
- decryptionKeys: privateKey,
357
- config: weakKeyConfig,
358
- });
359
- // Update last_used_at
360
- db.update('keypair', { key: 'id', value: defaultKeypair.id }, { last_used_at: new Date().toISOString() });
361
- return decrypted;
321
+ throw new Error('Could not decrypt with any of your matching keys');
362
322
  }
363
- function checkEditorAvailable(command) {
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;
335
+ }
336
+ }
337
+ }
338
+ // ---------- Auto-save contact (silent) ----------
339
+ async function autoSaveContact(publicKeyArmored) {
364
340
  try {
365
- execSync(`which ${command}`, { stdio: 'ignore' });
366
- return true;
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
+ },
353
+ });
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
+ },
363
+ });
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.`);
367
380
  }
368
381
  catch {
369
- return false;
382
+ // Silently skip on any failure
370
383
  }
371
384
  }
372
- function detectAvailableEditors() {
373
- const editors = [
374
- { name: 'VS Code', command: 'code', available: false },
375
- { name: 'Neovim', command: 'nvim', available: false },
376
- { name: 'Vim', command: 'vim', available: false },
377
- { name: 'Nano', command: 'nano', available: false },
378
- { name: 'Emacs', command: 'emacs', available: false },
379
- ];
380
- // Check platform specific editors
381
- if (process.platform === 'darwin') {
382
- editors.push({ name: 'TextEdit', command: 'open -e', available: true });
383
- }
384
- else if (process.platform === 'win32') {
385
- editors.push({ name: 'Notepad', command: 'notepad', available: true });
386
- }
387
- // Check which editors are available
388
- for (const editor of editors) {
389
- if (editor.command.includes('open -e') || editor.command === 'notepad') {
390
- editor.available = true; // TextEdit and Notepad are always available on their platforms
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
+ });
391
394
  }
392
- else {
393
- editor.available = checkEditorAvailable(editor.command);
395
+ if (availableEditors.length > 0) {
396
+ inputChoices.push({
397
+ name: `${icons.editor} Open in an editor`,
398
+ value: 'editor',
399
+ });
394
400
  }
395
- }
396
- return editors.filter((e) => e.available);
397
- }
398
- async function readInlineMultilineInput(promptText) {
399
- console.log(promptMessage(promptText));
400
- console.log(colors.muted('(Type your message. Press Enter, then Ctrl+D to finish)\n'));
401
- const rl = readline.createInterface({ input, output });
402
- rl.setPrompt('');
403
- const lines = [];
404
- return new Promise((resolve) => {
405
- rl.on('line', (line) => {
406
- lines.push(line);
401
+ inputChoices.push({
402
+ name: `${icons.inline} Type inline ${colors.muted('(Ctrl+D when done)')}`,
403
+ value: 'inline',
407
404
  });
408
- rl.on('close', () => {
409
- resolve(lines.join('\n'));
410
- });
411
- });
412
- }
413
- function extractAllPublicKeys(content) {
414
- const keyRegex = /-----BEGIN PGP PUBLIC KEY BLOCK-----[\s\S]*?-----END PGP PUBLIC KEY BLOCK-----/g;
415
- const matches = content.match(keyRegex);
416
- return matches || [];
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.');
424
+ continue;
425
+ }
426
+ return { value: content };
427
+ }
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
+ }
483
+ }
484
+ }
417
485
  }
418
- async function addKeysFromClipboard(recipients) {
419
- let clipboardContent = '';
486
+ async function getRecipientFromPaste() {
487
+ let value;
420
488
  try {
421
- clipboardContent = await clipboardy.read();
489
+ value = await readInlineMultiline("Paste the recipient's PGP PUBLIC KEY block:", '(Paste, then press Ctrl+D)');
422
490
  }
423
- catch {
424
- showWarning('Could not access clipboard');
425
- return 0;
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;
426
499
  }
427
- const keys = extractAllPublicKeys(clipboardContent);
500
+ const keys = extractAllPublicKeys(value);
428
501
  if (keys.length === 0) {
429
- showWarning('No public keys found in clipboard');
430
- return 0;
502
+ showError('No valid PGP public key block found.');
503
+ return null;
431
504
  }
432
- let addedCount = 0;
433
- for (const publicKey of keys) {
434
- try {
435
- // Validate the key
436
- await openpgp.readKey({ armoredKey: publicKey, config: weakKeyConfig });
437
- const keyInfo = await extractPublicKeyInfo(publicKey);
438
- const recipientName = keyInfo.email || keyInfo.fingerprint?.slice(-8) || 'Unknown';
439
- // Check for duplicates
440
- const isDuplicate = recipients.some((r) => r.publicKey === publicKey);
441
- if (isDuplicate) {
442
- showWarning(`Skipping duplicate key: ${recipientName}`);
443
- continue;
444
- }
445
- recipients.push({
446
- name: recipientName,
447
- publicKey,
448
- });
449
- showSuccess(`Added recipient: ${recipientName}`);
450
- addedCount++;
451
- }
452
- catch (error) {
453
- showError(`Failed to parse a key: ${error instanceof Error ? error.message : 'unknown error'}`);
454
- }
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;
455
515
  }
456
- return addedCount;
457
516
  }
458
517
  async function selectMultipleRecipients() {
459
518
  const recipients = [];
460
519
  const contacts = db.select({ table: 'contact' });
461
- const defaultKeypair = await keyManager.getDefaultKeypair();
462
- // Build the menu choices
463
- function buildChoices() {
520
+ const defaultKeypair = keyManager.getDefaultKeypair();
521
+ while (true) {
464
522
  const choices = [];
465
- // Show current recipients count
466
523
  if (recipients.length > 0) {
467
524
  choices.push({
468
525
  name: colors.primary(`── Current recipients: ${recipients.length} ──`),
469
- value: 'show-recipients',
526
+ value: 'show',
470
527
  });
471
528
  }
472
- // Option to add self (if not already added)
473
- const selfAdded = recipients.some((r) => r.name === 'Myself');
474
- if (defaultKeypair && !selfAdded) {
529
+ if (defaultKeypair && !recipients.some((r) => r.name === 'Myself')) {
475
530
  choices.push({
476
- 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)')}`,
477
532
  value: 'self',
478
533
  });
479
534
  }
480
- // Option to select from contacts
481
535
  if (contacts.length > 0) {
482
536
  choices.push({
483
- name: `${icons.contact} Select from saved contacts ${colors.muted(`(${contacts.length} available)`)}`,
537
+ name: `${icons.contact} Pick from contacts ${colors.muted(`(${contacts.length})`)}`,
484
538
  value: 'contacts',
485
539
  });
486
540
  }
487
- // Clipboard and manual options
488
- choices.push({
489
- name: `${icons.clipboard} Paste from clipboard ${colors.muted('(supports multiple keys)')}`,
490
- value: 'clipboard',
491
- });
492
- choices.push({
493
- name: `${icons.inline} Type/paste a single key`,
494
- value: 'manual',
495
- });
496
- // Done or cancel
497
- 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(), {
498
542
  name: recipients.length > 0
499
- ? `${icons.success} Done adding recipients`
543
+ ? `${icons.success} Done`
500
544
  : `${icons.back} Cancel`,
501
545
  value: 'done',
502
546
  });
503
- return choices;
504
- }
505
- let addMore = true;
506
- while (addMore) {
507
- const { addMethod } = await escapeablePrompt([
547
+ const { method } = await escapeablePrompt([
508
548
  {
509
549
  type: 'list',
510
- name: 'addMethod',
550
+ name: 'method',
511
551
  message: promptMessage('Add recipients:'),
512
- choices: buildChoices(),
552
+ choices,
513
553
  },
514
554
  ]);
515
- if (addMethod === 'done') {
516
- addMore = false;
517
- }
518
- else if (addMethod === 'show-recipients') {
519
- // Show current recipients
555
+ if (method === 'done')
556
+ break;
557
+ if (method === 'show') {
520
558
  console.log(colors.primary('\nCurrent recipients:'));
521
- for (const r of recipients) {
559
+ for (const r of recipients)
522
560
  console.log(colors.muted(` • ${r.name}`));
523
- }
524
561
  console.log();
562
+ continue;
525
563
  }
526
- else if (addMethod === 'self') {
564
+ if (method === 'self') {
527
565
  if (defaultKeypair) {
528
566
  recipients.push({
529
567
  name: 'Myself',
530
568
  publicKey: defaultKeypair.public_key,
569
+ isNew: false,
531
570
  });
532
- showSuccess('Added yourself as a recipient');
571
+ showSuccess('Added yourself');
533
572
  }
573
+ continue;
534
574
  }
535
- else if (addMethod === 'contacts') {
536
- // Show contacts as a checkbox
537
- const { selectedContacts } = await escapeablePrompt([
575
+ if (method === 'contacts') {
576
+ const { selected } = await escapeablePrompt([
538
577
  {
539
578
  type: 'checkbox',
540
- name: 'selectedContacts',
541
- message: promptMessage('Select contacts (space to toggle, enter to confirm):'),
579
+ name: 'selected',
580
+ message: promptMessage('Select contacts:'),
542
581
  choices: contacts.map((c) => {
543
- const alreadyAdded = recipients.some((r) => r.publicKey === c.public_key);
582
+ const already = recipients.some((r) => r.publicKey === c.public_key);
544
583
  return {
545
- name: `${c.name} <${c.email || 'no email'}>${alreadyAdded ? colors.muted(' (already added)') : ''}`,
584
+ name: `${c.name} <${c.email}>${already ? colors.muted(' (added)') : ''}`,
546
585
  value: c.id,
547
- checked: false,
548
- disabled: alreadyAdded,
586
+ disabled: already,
549
587
  };
550
588
  }),
551
589
  },
552
590
  ]);
553
- let addedCount = 0;
554
- for (const contactId of selectedContacts) {
555
- const contact = contacts.find((c) => c.id === contactId);
556
- if (contact) {
591
+ for (const id of selected) {
592
+ const c = contacts.find((x) => x.id === id);
593
+ if (c) {
557
594
  recipients.push({
558
- name: `${contact.name} <${contact.email || 'no email'}>`,
559
- 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,
560
602
  });
561
- addedCount++;
562
603
  }
563
604
  }
564
- if (addedCount > 0) {
565
- showSuccess(`Added ${addedCount} contact${addedCount > 1 ? 's' : ''}`);
566
- }
605
+ continue;
567
606
  }
568
- else if (addMethod === 'clipboard') {
569
- const added = await addKeysFromClipboard(recipients);
570
- if (added > 0) {
571
- console.log();
572
- showSuccess(`Added ${added} recipient${added > 1 ? 's' : ''} from clipboard`);
573
- 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;
574
613
  }
575
- }
576
- else if (addMethod === 'manual') {
577
- const publicKey = await getRecipientPublicKey();
578
- if (publicKey) {
614
+ for (const armored of keys) {
579
615
  try {
580
- const keyInfo = await extractPublicKeyInfo(publicKey);
581
- const recipientName = keyInfo.email || keyInfo.fingerprint?.slice(-8) || 'Unknown';
582
- // Check for duplicates
583
- const isDuplicate = recipients.some((r) => r.publicKey === publicKey);
584
- if (isDuplicate) {
585
- showWarning('This recipient is already in the list');
586
- }
587
- else {
588
- recipients.push({
589
- name: recipientName,
590
- publicKey,
591
- });
592
- 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;
593
622
  }
623
+ recipients.push({ name, publicKey: armored, isNew: true });
624
+ showSuccess(`Added ${name}`);
594
625
  }
595
626
  catch (error) {
596
- 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}`);
597
641
  }
598
642
  }
599
643
  }
600
644
  }
601
645
  return recipients;
602
646
  }
603
- async function getRecipientPublicKey() {
604
- // Check clipboard for public key
605
- let clipboardContent = '';
606
- let hasPublicKeyInClipboard = false;
607
- try {
608
- clipboardContent = await clipboardy.read();
609
- 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;
610
654
  }
611
- catch (e) {
612
- // Clipboard not available, continue without it
655
+ let selected;
656
+ if (keypairs.length === 1) {
657
+ selected = keypairs[0];
613
658
  }
614
- let publicKey = '';
615
- // If public key found in clipboard, ask if user wants to use it
616
- if (hasPublicKeyInClipboard) {
617
- const { useClipboard } = await escapeablePrompt([
659
+ else {
660
+ const defaultId = keypairs.find((kp) => kp.is_default)?.id;
661
+ const { keypairId } = await escapeablePrompt([
618
662
  {
619
- type: 'confirm',
620
- name: 'useClipboard',
621
- message: 'Public key detected in clipboard. Use it?',
622
- 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
+ ],
623
675
  },
624
676
  ]);
625
- if (useClipboard) {
626
- const publicMatch = clipboardContent.match(/-----BEGIN PGP PUBLIC KEY BLOCK-----[\s\S]*?-----END PGP PUBLIC KEY BLOCK-----/);
627
- if (publicMatch) {
628
- 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 }];
629
717
  }
630
718
  }
719
+ catch {
720
+ // Bad key, fall through to picker
721
+ }
631
722
  }
632
- // If no key from clipboard, prompt for input
633
- if (!publicKey) {
634
- console.log(promptMessage("\nPaste the recipient's PGP PUBLIC key:"));
635
- console.log(colors.muted('(Press Enter to finish, or press Enter then Ctrl+D)\n'));
636
- const rl = readline.createInterface({ input, output });
637
- rl.setPrompt('');
638
- const lines = [];
639
- publicKey = await new Promise((resolve) => {
640
- rl.on('line', (line) => {
641
- lines.push(line);
642
- const content = lines.join('\n');
643
- // Check if we have a complete key block and current line is empty
644
- if (line.trim() === '' &&
645
- content.includes('-----BEGIN PGP PUBLIC KEY BLOCK') &&
646
- content.includes('-----END PGP PUBLIC KEY BLOCK')) {
647
- rl.close();
648
- resolve(content.trim());
649
- }
650
- });
651
- rl.on('close', () => {
652
- 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',
653
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',
654
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
+ }
655
814
  }
656
- // Validate public key format
657
- if (!publicKey.includes('BEGIN PGP PUBLIC KEY BLOCK')) {
658
- console.log();
659
- 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}`));
660
821
  console.log();
661
- return null;
662
822
  }
663
- // 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…');
664
890
  try {
665
- 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();
666
898
  console.log();
667
- showSuccess('Valid public key');
899
+ const copied = await writeClipboardSafe(plaintext);
900
+ if (copied)
901
+ showSuccess('Decrypted message copied to clipboard.');
668
902
  console.log();
669
- return publicKey;
903
+ await pause();
670
904
  }
671
905
  catch (error) {
906
+ if (error instanceof EscapeError)
907
+ throw error;
908
+ const msg = error instanceof Error ? error.message : String(error);
672
909
  console.log();
673
- showError(`Failed to read public key: ${error instanceof Error ? error.message : error}`);
674
- return null;
910
+ showError(`Decryption failed: ${msg}`);
911
+ console.log();
912
+ await pause();
675
913
  }
676
914
  }
677
- // printBanner is imported from ui.ts
678
- function getEditorInstructions(editorCommand) {
679
- const instructions = {
680
- nano: 'Save: Ctrl+O, then Enter. Exit: Ctrl+X',
681
- vim: 'Save and exit: :wq | Cancel: :q!',
682
- nvim: 'Save and exit: :wq | Cancel: :q!',
683
- code: 'Save: Cmd/Ctrl+S, then close the editor tab',
684
- emacs: 'Save: Ctrl+X Ctrl+S | Exit: Ctrl+X Ctrl+C',
685
- 'open -e': 'Save: Cmd+S, then close the window',
686
- notepad: 'Save: Ctrl+S, then close the window',
687
- };
688
- return instructions[editorCommand] || 'Save and close the editor when done';
689
- }
690
- function clearPassphraseCache() {
691
- // Clear all cached passphrases from memory
692
- 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
+ ]);
693
923
  }
694
- async function main() {
695
- // Initialize database on first run
696
- if (!db) {
697
- db = await Db.init();
698
- keyManager = new KeyManager(db);
699
- }
924
+ // ---------- Main menu ----------
925
+ async function showMainMenu() {
700
926
  printBanner();
701
- // Check for default keypair on first run
702
- const hasKeypair = await keyManager.hasDefaultKeypair();
703
- if (!hasKeypair) {
704
- console.log();
705
- showWarning("No keypair found. Let's set up your first keypair.");
706
- console.log();
707
- await keyManager.setupFirstKeypair();
708
- console.log();
709
- showSuccess('Setup complete! You can now use the tool.');
710
- console.log();
711
- }
712
- // Build menu choices
927
+ const defaultKp = keyManager.getDefaultKeypair();
928
+ printHomeStatus(defaultKp ? `${defaultKp.name} key` : null);
713
929
  const menuChoices = [
714
- { name: `${icons.encrypt} Encrypt a message`, value: 'encrypt' },
930
+ { name: `${icons.clipboard} Copy my public key`, value: 'copy' },
715
931
  { name: `${icons.decrypt} Decrypt a message`, value: 'decrypt' },
716
- { 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(),
717
936
  ];
718
- // Check if installed globally and if update is available
719
- const installedVersion = getInstalledVersion();
720
- const latestVersion = getLatestVersion();
721
- if (!installedVersion) {
722
- // Not installed globally - offer to install
723
- menuChoices.push(new inquirer.Separator());
724
- menuChoices.push({
725
- name: `${icons.add} Install lpgp globally ${colors.muted('(for offline use)')}`,
726
- value: 'install',
727
- });
728
- }
729
- else if (latestVersion &&
730
- isOlderVersion(installedVersion, latestVersion)) {
731
- // Installed but outdated - offer to update
732
- menuChoices.push(new inquirer.Separator());
733
- menuChoices.push({
734
- name: `${icons.add} Update lpgp ${colors.muted(`(${installedVersion} → ${latestVersion})`)}`,
735
- value: 'update',
736
- });
737
- }
738
- menuChoices.push(new inquirer.Separator());
739
- menuChoices.push(exitChoice());
740
937
  const { action } = await escapeablePrompt([
741
938
  {
742
939
  type: 'list',
743
940
  name: 'action',
744
- message: promptMessage('What would you like to do?'),
941
+ message: promptMessage('Choose an action'),
745
942
  choices: menuChoices,
746
943
  },
747
944
  ]);
748
945
  if (action === 'exit') {
749
- clearPassphraseCache();
946
+ clearSession();
750
947
  console.clear();
751
948
  process.exit(0);
752
949
  }
753
- if (action === 'install' || action === 'update') {
754
- await installOrUpdateGlobally(action === 'update');
755
- await escapeablePrompt([
756
- {
757
- type: 'input',
758
- name: 'continue',
759
- message: promptMessage('Press Enter to continue...'),
760
- },
761
- ]);
762
- return main();
763
- }
764
950
  if (action === 'keys') {
765
951
  await keyManager.showKeyManagementMenu();
766
- return main();
952
+ return;
953
+ }
954
+ if (action === 'copy') {
955
+ await actionCopyPublicKey();
956
+ return;
767
957
  }
768
958
  if (action === 'encrypt') {
769
- try {
770
- // Ask who to encrypt for
771
- const { recipient } = await escapeablePrompt([
772
- {
773
- type: 'list',
774
- name: 'recipient',
775
- message: promptMessage('Who do you want to encrypt this message for?'),
776
- choices: [
777
- {
778
- name: `${icons.contact} Someone else ${colors.muted('(use their public key)')}`,
779
- value: 'other',
780
- },
781
- {
782
- name: `${icons.multiple} Multiple recipients`,
783
- value: 'multiple',
784
- },
785
- {
786
- name: `${icons.key} Myself ${colors.muted('(use my public key)')}`,
787
- value: 'self',
788
- },
789
- new inquirer.Separator(),
790
- mainMenuChoice(),
791
- ],
792
- },
793
- ]);
794
- if (recipient === 'back' || recipient === 'main-menu') {
795
- return main();
796
- }
797
- let recipientPublicKeys = [];
798
- let recipientNames = [];
799
- let isNewContact = false;
800
- // Handle multiple recipients
801
- if (recipient === 'multiple') {
802
- const recipients = await selectMultipleRecipients();
803
- if (recipients.length === 0) {
804
- console.log();
805
- showError('No recipients selected. Aborting.');
806
- console.log();
807
- return main();
808
- }
809
- recipientPublicKeys = recipients.map((r) => r.publicKey);
810
- recipientNames = recipients.map((r) => r.name);
811
- // Show summary
812
- console.log(colors.primary('\nEncrypting for the following recipients:'));
813
- for (const name of recipientNames) {
814
- console.log(colors.muted(` • ${name}`));
815
- }
816
- console.log();
817
- }
818
- else if (recipient === 'other') {
819
- // Check if there are any saved contacts
820
- const contacts = db.select({ table: 'contact' });
821
- // Loop for recipient selection (allows going back from contacts submenu)
822
- recipientLoop: while (true) {
823
- // Build main menu choices
824
- const recipientChoices = [];
825
- if (contacts.length > 0) {
826
- recipientChoices.push({
827
- name: `${icons.contact} Saved contacts ${colors.muted(`(${contacts.length} available)`)}`,
828
- value: 'saved-contacts',
829
- });
830
- }
831
- recipientChoices.push({ name: `${icons.add} Use a new public key`, value: 'new' }, new inquirer.Separator(), mainMenuChoice());
832
- const { recipientSource } = await escapeablePrompt([
833
- {
834
- type: 'list',
835
- name: 'recipientSource',
836
- message: promptMessage('How would you like to specify the recipient?'),
837
- choices: recipientChoices,
838
- },
839
- ]);
840
- if (recipientSource === 'main' || recipientSource === 'main-menu') {
841
- return main();
842
- }
843
- if (recipientSource === 'saved-contacts') {
844
- // Show contacts submenu
845
- const contactChoices = contacts.map((c) => ({
846
- name: `${icons.contact} ${c.name} ${colors.muted(`<${c.email}>`)}`,
847
- value: c.id,
848
- }));
849
- contactChoices.push(new inquirer.Separator(), backChoice(), mainMenuChoice(), new inquirer.Separator());
850
- const { contactChoice } = await escapeablePrompt([
851
- {
852
- type: 'list',
853
- name: 'contactChoice',
854
- message: promptMessage('Select a contact:'),
855
- choices: contactChoices,
856
- },
857
- ]);
858
- if (contactChoice === 'main' || contactChoice === 'main-menu') {
859
- return main();
860
- }
861
- if (contactChoice === 'back') {
862
- // Go back to recipient source selection
863
- continue recipientLoop;
864
- }
865
- // Use saved contact
866
- const selectedContact = contacts.find((c) => c.id === contactChoice);
867
- if (selectedContact) {
868
- recipientPublicKeys = [selectedContact.public_key];
869
- break recipientLoop;
870
- }
871
- }
872
- else if (recipientSource === 'new') {
873
- const publicKey = await getRecipientPublicKey();
874
- if (!publicKey) {
875
- console.log();
876
- showError('Could not get recipient public key. Aborting.');
877
- console.log();
878
- return main();
879
- }
880
- recipientPublicKeys = [publicKey];
881
- isNewContact = true;
882
- break recipientLoop;
883
- }
884
- }
885
- }
886
- // Detect available editors
887
- const availableEditors = detectAvailableEditors();
888
- let message;
889
- // Loop for input method selection (allows going back from editor selection)
890
- inputMethodLoop: while (true) {
891
- // Ask for input method
892
- const inputChoices = [];
893
- // Always add clipboard option first
894
- inputChoices.push({
895
- name: `${icons.clipboard} Paste from clipboard`,
896
- value: 'clipboard',
897
- });
898
- if (availableEditors.length > 0) {
899
- inputChoices.push({ name: `${icons.editor} Use an editor`, value: 'editor' }, {
900
- name: `${icons.inline} Type inline ${colors.muted('(Enter, then Ctrl+D to finish)')}`,
901
- value: 'inline',
902
- });
903
- }
904
- else {
905
- inputChoices.push({
906
- name: `${icons.inline} Type inline ${colors.muted('(Enter, then Ctrl+D to finish)')}`,
907
- value: 'inline',
908
- });
909
- }
910
- // Add main menu option
911
- inputChoices.push(new inquirer.Separator(), mainMenuChoice());
912
- const { inputMethod } = await escapeablePrompt([
913
- {
914
- type: 'list',
915
- name: 'inputMethod',
916
- message: promptMessage('How would you like to enter your message?'),
917
- choices: inputChoices,
918
- },
919
- ]);
920
- if (inputMethod === 'back' || inputMethod === 'main-menu') {
921
- return main();
922
- }
923
- if (inputMethod === 'clipboard') {
924
- try {
925
- message = await clipboardy.read();
926
- if (!message || message.trim() === '') {
927
- console.log();
928
- showError('Clipboard is empty.');
929
- console.log();
930
- return main();
931
- }
932
- console.log();
933
- showSuccess('Message loaded from clipboard');
934
- console.log();
935
- break inputMethodLoop;
936
- }
937
- catch (clipError) {
938
- console.log();
939
- showError(`Failed to read from clipboard: ${clipError}`);
940
- return main();
941
- }
942
- }
943
- else if (inputMethod === 'editor') {
944
- // Let user choose editor
945
- const editorChoices = availableEditors.map((e) => ({
946
- name: `${icons.editor} ${e.name} ${colors.muted(`(${getEditorInstructions(e.command)})`)}`,
947
- value: e.command,
948
- }));
949
- editorChoices.push(new inquirer.Separator(), backChoice(), mainMenuChoice());
950
- const { selectedEditor } = await escapeablePrompt([
951
- {
952
- type: 'list',
953
- name: 'selectedEditor',
954
- message: promptMessage('Choose your editor:'),
955
- choices: editorChoices,
956
- },
957
- ]);
958
- if (selectedEditor === 'back') {
959
- // Re-ask for input method
960
- continue inputMethodLoop;
961
- }
962
- if (selectedEditor === 'main-menu') {
963
- return main();
964
- }
965
- // Set the EDITOR environment variable before opening inquirer editor
966
- const originalEditor = process.env.EDITOR;
967
- const originalVisual = process.env.VISUAL;
968
- process.env.EDITOR = selectedEditor;
969
- process.env.VISUAL = selectedEditor;
970
- const editorName = availableEditors.find((e) => e.command === selectedEditor)
971
- ?.name || 'editor';
972
- console.log(colors.muted('\nNote: The temp file is automatically deleted after encryption.\n'));
973
- try {
974
- const { editorInput } = await escapeablePrompt([
975
- {
976
- type: 'editor',
977
- name: 'editorInput',
978
- message: promptMessage(`Press Enter to open ${editorName}:`),
979
- postfix: '.txt',
980
- waitForUseInput: false,
981
- },
982
- ]);
983
- message = editorInput;
984
- break inputMethodLoop;
985
- }
986
- finally {
987
- // Restore original environment variables
988
- if (originalEditor !== undefined) {
989
- process.env.EDITOR = originalEditor;
990
- }
991
- else {
992
- delete process.env.EDITOR;
993
- }
994
- if (originalVisual !== undefined) {
995
- process.env.VISUAL = originalVisual;
996
- }
997
- else {
998
- delete process.env.VISUAL;
999
- }
1000
- }
1001
- }
1002
- else {
1003
- message = await readInlineMultilineInput('Enter your message:');
1004
- break inputMethodLoop;
1005
- }
1006
- }
1007
- if (!message || message.trim() === '') {
1008
- console.log();
1009
- showError('No message provided. Aborting.');
1010
- console.log();
1011
- return main();
1012
- }
1013
- console.log();
1014
- showLoading('Encrypting message...');
1015
- console.log();
1016
- const encrypted = await encryptMessage(message, recipientPublicKeys.length > 0 ? recipientPublicKeys : undefined);
1017
- // Clear screen, show encrypted message, then clipboard status
1018
- console.clear();
1019
- printBanner();
1020
- console.log(colors.successBold('Encrypted Message:\n'));
1021
- printDivider();
1022
- console.log(encrypted);
1023
- printDivider();
1024
- // Copy to clipboard and show status below the message
1025
- try {
1026
- await clipboardy.write(encrypted);
1027
- console.log();
1028
- showSuccess('Encrypted message copied to clipboard');
1029
- console.log();
1030
- }
1031
- catch (clipError) {
1032
- console.log();
1033
- showWarning('Clipboard unavailable');
1034
- console.log();
1035
- }
1036
- // Offer to save the contact if it's a new public key (single recipient only)
1037
- const newPublicKey = recipientPublicKeys[0];
1038
- if (isNewContact &&
1039
- newPublicKey !== undefined &&
1040
- recipientPublicKeys.length === 1) {
1041
- const { saveContact } = await escapeablePrompt([
1042
- {
1043
- type: 'confirm',
1044
- name: 'saveContact',
1045
- message: promptMessage('Would you like to save this contact for future use?'),
1046
- default: true,
1047
- },
1048
- ]);
1049
- if (saveContact) {
1050
- try {
1051
- // Extract key information
1052
- const keyInfo = await extractPublicKeyInfo(newPublicKey);
1053
- // Prompt for contact name
1054
- const defaultName = (keyInfo.email || 'unknown').split('@')[0] || 'Contact';
1055
- const answers = await escapeablePrompt([
1056
- {
1057
- type: 'input',
1058
- name: 'contactName',
1059
- message: promptMessage('Contact name:'),
1060
- default: defaultName,
1061
- validate: (input) => input.trim().length > 0 || 'Name cannot be empty',
1062
- },
1063
- ]);
1064
- const contactName = answers.contactName;
1065
- // Check if contact already exists by fingerprint
1066
- const existingContacts = db.select({
1067
- table: 'contact',
1068
- where: {
1069
- key: 'fingerprint',
1070
- compare: 'is',
1071
- value: keyInfo.fingerprint,
1072
- },
1073
- });
1074
- if (existingContacts.length > 0) {
1075
- console.log();
1076
- showWarning('This contact already exists.');
1077
- console.log();
1078
- }
1079
- else {
1080
- // Save the contact
1081
- db.insert('contact', {
1082
- name: contactName.trim(),
1083
- email: keyInfo.email,
1084
- fingerprint: keyInfo.fingerprint,
1085
- public_key: newPublicKey,
1086
- algorithm: keyInfo.algorithm,
1087
- key_size: keyInfo.keySize,
1088
- trusted: false,
1089
- last_verified_at: null,
1090
- notes: null,
1091
- expires_at: keyInfo.expiresAt,
1092
- revoked: false,
1093
- });
1094
- console.log();
1095
- showSuccess(`Contact "${contactName}" saved successfully!`);
1096
- console.log();
1097
- }
1098
- }
1099
- catch (error) {
1100
- console.log();
1101
- showError(`Failed to save contact: ${error instanceof Error ? error.message : error}`);
1102
- }
1103
- }
1104
- }
1105
- }
1106
- catch (error) {
1107
- // Re-throw escape errors to be handled by the main loop
1108
- if (error instanceof EscapeError)
1109
- throw error;
1110
- console.log();
1111
- showError(`Encryption failed: ${error instanceof Error ? error.message : error}`);
1112
- }
959
+ await actionEncrypt();
960
+ return;
1113
961
  }
1114
- else if (action === 'decrypt') {
1115
- try {
1116
- // Detect available editors
1117
- const availableEditors = detectAvailableEditors();
1118
- let encrypted;
1119
- // Loop for input method selection (allows going back from editor selection)
1120
- decryptInputLoop: while (true) {
1121
- // Ask for input method
1122
- const inputChoices = [];
1123
- // Always add clipboard option first
1124
- inputChoices.push({
1125
- name: `${icons.clipboard} Paste from clipboard`,
1126
- value: 'clipboard',
1127
- });
1128
- if (availableEditors.length > 0) {
1129
- inputChoices.push({ name: `${icons.editor} Use an editor`, value: 'editor' }, {
1130
- name: `${icons.inline} Type inline ${colors.muted('(Enter, then Ctrl+D to finish)')}`,
1131
- value: 'inline',
1132
- });
1133
- }
1134
- else {
1135
- inputChoices.push({
1136
- name: `${icons.inline} Type inline ${colors.muted('(Enter, then Ctrl+D to finish)')}`,
1137
- value: 'inline',
1138
- });
1139
- }
1140
- // Add main menu option
1141
- inputChoices.push(new inquirer.Separator(), mainMenuChoice());
1142
- const { inputMethod } = await escapeablePrompt([
1143
- {
1144
- type: 'list',
1145
- name: 'inputMethod',
1146
- message: promptMessage('How would you like to enter the encrypted message?'),
1147
- choices: inputChoices,
1148
- },
1149
- ]);
1150
- if (inputMethod === 'back' || inputMethod === 'main-menu') {
1151
- return main();
1152
- }
1153
- if (inputMethod === 'clipboard') {
1154
- try {
1155
- encrypted = await clipboardy.read();
1156
- if (!encrypted || encrypted.trim() === '') {
1157
- console.log();
1158
- showError('Clipboard is empty.');
1159
- console.log();
1160
- return main();
1161
- }
1162
- console.log();
1163
- showSuccess('Encrypted message loaded from clipboard');
1164
- console.log();
1165
- break decryptInputLoop;
1166
- }
1167
- catch (clipError) {
1168
- console.log();
1169
- showError(`Failed to read from clipboard: ${clipError}`);
1170
- return main();
1171
- }
1172
- }
1173
- else if (inputMethod === 'editor') {
1174
- // Let user choose editor
1175
- const editorChoices = availableEditors.map((e) => ({
1176
- name: `${icons.editor} ${e.name} ${colors.muted(`(${getEditorInstructions(e.command)})`)}`,
1177
- value: e.command,
1178
- }));
1179
- editorChoices.push(new inquirer.Separator(), backChoice(), mainMenuChoice());
1180
- const { selectedEditor } = await escapeablePrompt([
1181
- {
1182
- type: 'list',
1183
- name: 'selectedEditor',
1184
- message: promptMessage('Choose your editor:'),
1185
- choices: editorChoices,
1186
- },
1187
- ]);
1188
- if (selectedEditor === 'back') {
1189
- // Re-ask for input method
1190
- continue decryptInputLoop;
1191
- }
1192
- if (selectedEditor === 'main-menu') {
1193
- return main();
1194
- }
1195
- // Set the EDITOR environment variable before opening inquirer editor
1196
- const originalEditor = process.env.EDITOR;
1197
- const originalVisual = process.env.VISUAL;
1198
- process.env.EDITOR = selectedEditor;
1199
- process.env.VISUAL = selectedEditor;
1200
- const editorName = availableEditors.find((e) => e.command === selectedEditor)
1201
- ?.name || 'editor';
1202
- console.log(colors.muted('\nNote: The temp file is automatically deleted after decryption.\n'));
1203
- try {
1204
- const { editorInput } = await escapeablePrompt([
1205
- {
1206
- type: 'editor',
1207
- name: 'editorInput',
1208
- message: promptMessage(`Press Enter to open ${editorName}:`),
1209
- postfix: '.txt',
1210
- waitForUseInput: false,
1211
- },
1212
- ]);
1213
- encrypted = editorInput;
1214
- break decryptInputLoop;
1215
- }
1216
- finally {
1217
- // Restore original environment variables
1218
- if (originalEditor !== undefined) {
1219
- process.env.EDITOR = originalEditor;
1220
- }
1221
- else {
1222
- delete process.env.EDITOR;
1223
- }
1224
- if (originalVisual !== undefined) {
1225
- process.env.VISUAL = originalVisual;
1226
- }
1227
- else {
1228
- delete process.env.VISUAL;
1229
- }
1230
- }
1231
- }
1232
- else {
1233
- encrypted = await readInlineMultilineInput('Paste the encrypted message:');
1234
- break decryptInputLoop;
1235
- }
1236
- }
1237
- if (!encrypted || encrypted.trim() === '') {
1238
- console.log();
1239
- showError('No encrypted message provided. Aborting.');
1240
- console.log();
1241
- return main();
1242
- }
1243
- console.log();
1244
- showLoading('Decrypting message...');
1245
- console.log();
1246
- const decrypted = await decryptMessage(encrypted);
1247
- // Clear screen, show decrypted message, then clipboard status
1248
- console.clear();
1249
- printBanner();
1250
- console.log(colors.successBold('Decrypted Message:\n'));
1251
- printDivider();
1252
- console.log(decrypted);
1253
- printDivider();
1254
- // Copy to clipboard and show status below the message
1255
- try {
1256
- await clipboardy.write(decrypted);
1257
- console.log();
1258
- showSuccess('Decrypted message copied to clipboard');
1259
- console.log();
1260
- }
1261
- catch (clipError) {
1262
- console.log();
1263
- showWarning('Clipboard unavailable');
1264
- console.log();
1265
- }
1266
- // Wait for user to press Enter before continuing
1267
- await escapeablePrompt([
1268
- {
1269
- type: 'input',
1270
- name: 'continue',
1271
- message: colors.muted('Press Enter to continue...'),
1272
- },
1273
- ]);
1274
- }
1275
- catch (error) {
1276
- // Re-throw escape errors to be handled by the main loop
1277
- if (error instanceof EscapeError)
1278
- throw error;
1279
- console.log();
1280
- const errorMessage = error instanceof Error ? error.message : String(error);
1281
- // Provide more helpful error messages for common issues
1282
- if (errorMessage.includes('No decryption key packets found')) {
1283
- showError('Decryption failed: This message was not encrypted for your current default key.');
1284
- console.log();
1285
- console.log(colors.muted(' Tip: Check Key Management to verify the correct keypair is set as default.'));
1286
- }
1287
- else {
1288
- showError(`Decryption failed: ${errorMessage}`);
1289
- }
1290
- }
962
+ if (action === 'decrypt') {
963
+ await actionDecrypt();
964
+ return;
1291
965
  }
1292
- // Ask if user wants to continue
1293
- const { nextAction } = await escapeablePrompt([
1294
- {
1295
- type: 'list',
1296
- name: 'nextAction',
1297
- message: promptMessage('What would you like to do next?'),
1298
- choices: [
1299
- {
1300
- name: `${icons.loop} Perform another operation`,
1301
- value: 'continue',
1302
- },
1303
- exitChoice(),
1304
- ],
1305
- },
1306
- ]);
1307
- if (nextAction === 'continue') {
1308
- 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);
1309
975
  }
1310
- else {
1311
- clearPassphraseCache();
1312
- 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();
1313
986
  }
1314
- }
1315
- // Graceful exit on Ctrl+C
1316
- process.on('SIGINT', () => {
1317
- clearPassphraseCache();
1318
- console.clear();
1319
- process.exit(0);
1320
- });
1321
- // Enable global escape key handling and run menu in a loop
1322
- enableGlobalEscape();
1323
- 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();
1324
1000
  while (true) {
1325
1001
  try {
1326
- await main();
1002
+ await showMainMenu();
1327
1003
  }
1328
1004
  catch (error) {
1329
1005
  const e = error;
1330
- // If escape was pressed, just restart the menu
1331
1006
  if (error instanceof EscapeError ||
1332
1007
  checkAndResetEscape() ||
1333
1008
  e.message?.includes('prompt was closed')) {
1334
1009
  continue;
1335
1010
  }
1336
- // Handle Ctrl+C gracefully (inquirer throws ExitPromptError)
1337
1011
  if (e.message?.includes('force closed the prompt')) {
1338
- clearPassphraseCache();
1012
+ clearSession();
1339
1013
  console.clear();
1340
1014
  process.exit(0);
1341
1015
  }
1342
- // Handle other errors
1343
- clearPassphraseCache();
1344
- console.clear();
1016
+ clearSession();
1345
1017
  showError(`Error: ${e.message || error}`);
1346
- process.exit(1);
1018
+ await pause();
1347
1019
  }
1348
1020
  }
1349
1021
  }
1350
- runApp();
1351
- } // 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
+ }
1352
1033
  //# sourceMappingURL=pgp-tool.js.map