@cuytamvan/cuypwm 1.0.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.
@@ -0,0 +1,345 @@
1
+ import * as p from '@clack/prompts';
2
+ import chalk from 'chalk';
3
+ import { readFileSync, existsSync } from 'node:fs';
4
+ import {
5
+ readPublicKey,
6
+ readPrivateKeyPem,
7
+ getPrivateKeyPassphraseIfNeeded,
8
+ } from '../keys';
9
+ import { encryptData, decryptData } from '../crypto';
10
+ import { deleteEntry, updateEntry, loadUsers } from '../store';
11
+ import { entryLabel, detailBox, dangerBox } from '../ui';
12
+ import {
13
+ checkCancel,
14
+ resolvePath,
15
+ passwordOrGenerate,
16
+ pickFilteredEntries,
17
+ } from '../utils';
18
+ import type { PasswordEntry } from '../types';
19
+
20
+ export async function viewCredentialFlow() {
21
+ const { entries, total } = await pickFilteredEntries();
22
+
23
+ if (!total) {
24
+ p.log.warn(chalk.yellow('No credentials saved yet.'));
25
+ return;
26
+ }
27
+
28
+ if (!entries.length) {
29
+ p.log.warn(chalk.yellow('No credentials found for this user.'));
30
+ return;
31
+ }
32
+
33
+ const selected = checkCancel(
34
+ await p.select({
35
+ message: 'Select a credential',
36
+ options: entries.map((e) => ({ value: e.id, label: entryLabel(e) })),
37
+ }),
38
+ );
39
+
40
+ const entry = entries.find((e) => e.id === selected)!;
41
+
42
+ const subAction = checkCancel(
43
+ await p.select({
44
+ message: `Actions for '${chalk.bold(entry.source)}'`,
45
+ options: [
46
+ { value: 'view', label: `${chalk.cyan('👁 ')} View Detail` },
47
+ { value: 'edit', label: `${chalk.yellow('✏️ ')} Edit Credential` },
48
+ { value: 'delete', label: `${chalk.red('🗑 ')} Delete Credential` },
49
+ ],
50
+ }),
51
+ );
52
+
53
+ if (subAction === 'view') {
54
+ await showEntryDetail(entry);
55
+ } else if (subAction === 'edit') {
56
+ await editEntryFlow(entry);
57
+ } else if (subAction === 'delete') {
58
+ await confirmAndDeleteEntry(entry);
59
+ }
60
+ }
61
+
62
+ export async function showEntryDetail(entry: PasswordEntry) {
63
+ const privateKeyPem = readPrivateKeyPem();
64
+ const passphrase = await getPrivateKeyPassphraseIfNeeded();
65
+
66
+ const s = p.spinner();
67
+ s.start('🔐 Decrypting data...');
68
+
69
+ try {
70
+ let body = `${chalk.dim('Description')} : ${entry.description || '-'}\n`;
71
+
72
+ if (entry.type === 'ssh_key') {
73
+ const decryptedPrivate = decryptData(
74
+ privateKeyPem,
75
+ passphrase,
76
+ entry.encrypted_private_key,
77
+ );
78
+ body += `\n${chalk.bold.cyan('Public Key')}\n${chalk.gray(entry.public_key.trim())}\n`;
79
+ body += `\n${chalk.bold.cyan('Private Key')}\n${chalk.magentaBright(decryptedPrivate.trim())}\n`;
80
+ } else if (entry.type === 'ssh_cred') {
81
+ const decryptedPassword = decryptData(
82
+ privateKeyPem,
83
+ passphrase,
84
+ entry.encrypted_password,
85
+ );
86
+ body += `${chalk.dim('Host')} : ${chalk.whiteBright(entry.host)}\n`;
87
+ body += `${chalk.dim('Port')} : ${chalk.whiteBright(String(entry.port))}\n`;
88
+ body += `${chalk.dim('Username')} : ${chalk.whiteBright(entry.username)}\n`;
89
+ body += `${chalk.dim('Password')} : ${chalk.magentaBright.bold(decryptedPassword)}\n`;
90
+ } else {
91
+ const decryptedPassword = decryptData(
92
+ privateKeyPem,
93
+ passphrase,
94
+ entry.encrypted_password,
95
+ );
96
+ body += `${chalk.dim('Username')} : ${chalk.whiteBright(entry.username)}\n`;
97
+ body += `${chalk.dim('Password')} : ${chalk.magentaBright.bold(decryptedPassword)}\n`;
98
+ if (entry.extra) {
99
+ for (const [k, v] of Object.entries(entry.extra)) {
100
+ body += `${chalk.dim(k)} : ${chalk.whiteBright(v)}\n`;
101
+ }
102
+ }
103
+ }
104
+
105
+ s.stop(chalk.green('✔ Decrypted successfully.'));
106
+ console.log(detailBox(entry, body.trim()));
107
+ } catch (err) {
108
+ s.stop(chalk.red('✖ Decryption failed.'));
109
+ throw new Error(
110
+ 'Failed to decrypt data. Make sure the private key passphrase is correct. Detail: ' +
111
+ String(err),
112
+ );
113
+ }
114
+ }
115
+
116
+ export async function confirmAndDeleteEntry(entry: PasswordEntry) {
117
+ console.log(
118
+ dangerBox(
119
+ '⚠️ Confirm Delete',
120
+ `${entryLabel(entry)}\n${chalk.dim('This action cannot be undone.')}`,
121
+ ),
122
+ );
123
+
124
+ const confirmed = checkCancel(
125
+ await p.confirm({
126
+ message: `Are you sure you want to delete '${entry.source}'?`,
127
+ initialValue: false,
128
+ }),
129
+ );
130
+
131
+ if (!confirmed) {
132
+ p.log.info(chalk.dim('Cancelled.'));
133
+ return;
134
+ }
135
+
136
+ deleteEntry(entry.id);
137
+ p.log.success(
138
+ chalk.green(
139
+ `✔ Credential '${chalk.bold(entry.source)}' deleted successfully.`,
140
+ ),
141
+ );
142
+ }
143
+
144
+ export async function editEntryFlow(entry: PasswordEntry) {
145
+ const publicKey = readPublicKey();
146
+
147
+ const users = loadUsers();
148
+ let cred_user_id = entry.cred_user_id;
149
+ if (users.length) {
150
+ const userChoice = checkCancel(
151
+ await p.select({
152
+ message: 'Linked user',
153
+ options: [
154
+ { value: '__none__', label: chalk.dim('— No User —') },
155
+ ...users.map((u) => ({ value: u.id, label: u.name })),
156
+ ],
157
+ }),
158
+ ) as string;
159
+ cred_user_id = userChoice === '__none__' ? null : userChoice;
160
+ }
161
+
162
+ if (entry.type === 'ssh_key') {
163
+ const source = checkCancel(
164
+ await p.text({
165
+ message: 'Name / label (source)',
166
+ initialValue: entry.source,
167
+ }),
168
+ );
169
+ const description = checkCancel(
170
+ await p.text({
171
+ message: 'Description (optional)',
172
+ initialValue: entry.description,
173
+ defaultValue: '',
174
+ }),
175
+ );
176
+
177
+ const changeKey = checkCancel(
178
+ await p.confirm({ message: 'Replace key file?', initialValue: false }),
179
+ );
180
+
181
+ let encrypted_private_key = entry.encrypted_private_key;
182
+ let public_key = entry.public_key;
183
+
184
+ if (changeKey) {
185
+ const privateKeyPath = checkCancel(
186
+ await p.text({
187
+ message: 'New private key file path',
188
+ placeholder: '~/.ssh/id_rsa',
189
+ validate(v) {
190
+ if (!v) return 'Required';
191
+ if (!existsSync(resolvePath(v))) return 'File not found';
192
+ },
193
+ }),
194
+ );
195
+ const publicKeyPath = checkCancel(
196
+ await p.text({
197
+ message: 'New public key file path',
198
+ placeholder: '~/.ssh/id_rsa.pub',
199
+ validate(v) {
200
+ if (!v) return 'Required';
201
+ if (!existsSync(resolvePath(v))) return 'File not found';
202
+ },
203
+ }),
204
+ );
205
+ encrypted_private_key = encryptData(
206
+ publicKey,
207
+ readFileSync(resolvePath(privateKeyPath), 'utf8'),
208
+ );
209
+ public_key = readFileSync(resolvePath(publicKeyPath), 'utf8');
210
+ }
211
+
212
+ updateEntry({
213
+ ...entry,
214
+ source,
215
+ description: description || '',
216
+ cred_user_id,
217
+ encrypted_private_key,
218
+ public_key,
219
+ });
220
+ p.log.success(
221
+ chalk.green(`✔ SSH Key '${chalk.bold(source)}' updated successfully.`),
222
+ );
223
+ return;
224
+ }
225
+
226
+ if (entry.type === 'ssh_cred') {
227
+ const source = checkCancel(
228
+ await p.text({
229
+ message: 'Name / label (source)',
230
+ initialValue: entry.source,
231
+ }),
232
+ );
233
+ const host = checkCancel(
234
+ await p.text({ message: 'Host', initialValue: entry.host }),
235
+ );
236
+ const portInput = checkCancel(
237
+ await p.text({
238
+ message: 'Port',
239
+ initialValue: String(entry.port),
240
+ validate(v) {
241
+ if (v && Number.isNaN(Number(v))) return 'Port must be a number';
242
+ },
243
+ }),
244
+ );
245
+ const username = checkCancel(
246
+ await p.text({ message: 'Username', initialValue: entry.username }),
247
+ );
248
+ const description = checkCancel(
249
+ await p.text({
250
+ message: 'Description (optional)',
251
+ initialValue: entry.description,
252
+ defaultValue: '',
253
+ }),
254
+ );
255
+
256
+ const changePassword = checkCancel(
257
+ await p.confirm({ message: 'Change password?', initialValue: false }),
258
+ );
259
+ let encrypted_password = entry.encrypted_password;
260
+ if (changePassword) {
261
+ encrypted_password = encryptData(publicKey, await passwordOrGenerate());
262
+ }
263
+
264
+ updateEntry({
265
+ ...entry,
266
+ source,
267
+ host,
268
+ port: Number(portInput || entry.port),
269
+ username,
270
+ description: description || '',
271
+ cred_user_id,
272
+ encrypted_password,
273
+ });
274
+ p.log.success(
275
+ chalk.green(
276
+ `✔ SSH Credential '${chalk.bold(source)}' updated successfully.`,
277
+ ),
278
+ );
279
+ return;
280
+ }
281
+
282
+ // github, gitlab, gmail, bank, website
283
+ const source = checkCancel(
284
+ await p.text({ message: 'Source / Name', initialValue: entry.source }),
285
+ );
286
+ const username = checkCancel(
287
+ await p.text({
288
+ message: 'Username / Email / Account number',
289
+ initialValue: entry.username,
290
+ }),
291
+ );
292
+ const description = checkCancel(
293
+ await p.text({
294
+ message: 'Description (optional)',
295
+ initialValue: entry.description,
296
+ defaultValue: '',
297
+ }),
298
+ );
299
+
300
+ const changePassword = checkCancel(
301
+ await p.confirm({ message: 'Change password?', initialValue: false }),
302
+ );
303
+ let encrypted_password = entry.encrypted_password;
304
+ if (changePassword) {
305
+ encrypted_password = encryptData(publicKey, await passwordOrGenerate());
306
+ }
307
+
308
+ let extra = entry.extra;
309
+
310
+ if (entry.type === 'bank') {
311
+ const accountNumber = checkCancel(
312
+ await p.text({
313
+ message: 'Account number (optional)',
314
+ initialValue: entry.extra?.account_number || '',
315
+ defaultValue: '',
316
+ }),
317
+ );
318
+ if (accountNumber)
319
+ extra = { ...(extra || {}), account_number: accountNumber };
320
+ }
321
+
322
+ if (entry.type === 'website') {
323
+ const url = checkCancel(
324
+ await p.text({
325
+ message: 'Website URL (optional)',
326
+ initialValue: entry.extra?.url || '',
327
+ defaultValue: '',
328
+ }),
329
+ );
330
+ if (url) extra = { ...(extra || {}), url };
331
+ }
332
+
333
+ updateEntry({
334
+ ...entry,
335
+ source,
336
+ username,
337
+ description: description || '',
338
+ cred_user_id,
339
+ encrypted_password,
340
+ extra,
341
+ });
342
+ p.log.success(
343
+ chalk.green(`✔ Credential '${chalk.bold(source)}' updated successfully.`),
344
+ );
345
+ }
package/src/config.ts ADDED
@@ -0,0 +1,15 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { existsSync, mkdirSync } from 'node:fs';
4
+
5
+ export const APP_DIR = join(homedir(), '.cuy-pwm');
6
+ export const KEYS_DIR = join(APP_DIR, 'keys');
7
+ export const PRIVATE_KEY_PATH = join(KEYS_DIR, 'private.pem');
8
+ export const PUBLIC_KEY_PATH = join(KEYS_DIR, 'public.pem');
9
+ export const SOURCE_FILE = join(homedir(), 'cuypwm.db.json');
10
+ export const CONFIG_FILE = join(APP_DIR, 'config.json');
11
+
12
+ export function ensureAppDir() {
13
+ if (!existsSync(APP_DIR)) mkdirSync(APP_DIR, { recursive: true });
14
+ if (!existsSync(KEYS_DIR)) mkdirSync(KEYS_DIR, { recursive: true });
15
+ }
package/src/crypto.ts ADDED
@@ -0,0 +1,79 @@
1
+ import {
2
+ publicEncrypt,
3
+ privateDecrypt,
4
+ randomBytes,
5
+ createCipheriv,
6
+ createDecipheriv,
7
+ constants,
8
+ } from 'node:crypto';
9
+
10
+ interface EncryptedPayload {
11
+ k: string;
12
+ iv: string;
13
+ tag: string;
14
+ data: string;
15
+ }
16
+
17
+ export function encryptData(publicKeyPem: string, plaintext: string): string {
18
+ const aesKey = randomBytes(32);
19
+ const iv = randomBytes(12);
20
+
21
+ const cipher = createCipheriv('aes-256-gcm', aesKey, iv);
22
+ const encrypted = Buffer.concat([
23
+ cipher.update(plaintext, 'utf8'),
24
+ cipher.final(),
25
+ ]);
26
+ const tag = cipher.getAuthTag();
27
+
28
+ const encryptedKey = publicEncrypt(
29
+ {
30
+ key: publicKeyPem,
31
+ padding: constants.RSA_PKCS1_OAEP_PADDING,
32
+ oaepHash: 'sha256',
33
+ },
34
+ aesKey,
35
+ );
36
+
37
+ const payload: EncryptedPayload = {
38
+ k: encryptedKey.toString('base64'),
39
+ iv: iv.toString('base64'),
40
+ tag: tag.toString('base64'),
41
+ data: encrypted.toString('base64'),
42
+ };
43
+
44
+ return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64');
45
+ }
46
+
47
+ export function decryptData(
48
+ privateKeyPem: string,
49
+ passphrase: string | undefined,
50
+ encryptedString: string,
51
+ ): string {
52
+ const payload: EncryptedPayload = JSON.parse(
53
+ Buffer.from(encryptedString, 'base64').toString('utf8'),
54
+ );
55
+
56
+ const aesKey = privateDecrypt(
57
+ {
58
+ key: privateKeyPem,
59
+ passphrase,
60
+ padding: constants.RSA_PKCS1_OAEP_PADDING,
61
+ oaepHash: 'sha256',
62
+ },
63
+ Buffer.from(payload.k, 'base64'),
64
+ );
65
+
66
+ const decipher = createDecipheriv(
67
+ 'aes-256-gcm',
68
+ aesKey,
69
+ Buffer.from(payload.iv, 'base64'),
70
+ );
71
+ decipher.setAuthTag(Buffer.from(payload.tag, 'base64'));
72
+
73
+ const decrypted = Buffer.concat([
74
+ decipher.update(Buffer.from(payload.data, 'base64')),
75
+ decipher.final(),
76
+ ]);
77
+
78
+ return decrypted.toString('utf8');
79
+ }
@@ -0,0 +1,84 @@
1
+ import * as p from '@clack/prompts';
2
+
3
+ const CHARSETS = {
4
+ upper: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
5
+ lower: 'abcdefghijklmnopqrstuvwxyz',
6
+ numeric: '0123456789',
7
+ symbol: '!@#$%^&*()_+-=[]{}|;:,.<>?',
8
+ } as const;
9
+
10
+ type CharsetKey = keyof typeof CHARSETS;
11
+
12
+ export async function promptGeneratePassword(): Promise<string | undefined> {
13
+ const options = await p.multiselect({
14
+ message:
15
+ 'Select character types for password (space to select, enter to continue)',
16
+ options: [
17
+ { value: 'upper', label: 'UPPERCASE (A-Z)' },
18
+ { value: 'lower', label: 'lowercase (a-z)' },
19
+ { value: 'numeric', label: 'Numeric (0-9)' },
20
+ { value: 'symbol', label: 'Symbol (!@#$%^&*...)' },
21
+ ],
22
+ required: true,
23
+ });
24
+
25
+ if (p.isCancel(options)) return undefined;
26
+
27
+ const selected = options as CharsetKey[];
28
+
29
+ if (!selected.length) {
30
+ p.log.error('Select at least 1 character type.');
31
+ return undefined;
32
+ }
33
+
34
+ const lengthInput = await p.text({
35
+ message: 'Password length',
36
+ placeholder: '8',
37
+ initialValue: '8',
38
+ validate(value) {
39
+ if (!value) return;
40
+ const n = Number(value);
41
+ if (Number.isNaN(n) || !Number.isInteger(n))
42
+ return 'Must be a whole number';
43
+ if (n < 4) return 'Minimum length is 4 characters';
44
+ if (n > 128) return 'Maximum length is 128 characters';
45
+ },
46
+ });
47
+
48
+ if (p.isCancel(lengthInput)) return undefined;
49
+
50
+ const length = Number(lengthInput || '8');
51
+
52
+ return generatePassword(selected, length);
53
+ }
54
+
55
+ export function generatePassword(
56
+ selected: CharsetKey[],
57
+ length: number,
58
+ ): string {
59
+ const pools = selected.map((s) => CHARSETS[s]);
60
+ const allChars = pools.join('');
61
+
62
+ const result: string[] = [];
63
+
64
+ for (const pool of pools) {
65
+ result.push(pool[randomInt(pool.length)]!);
66
+ }
67
+
68
+ while (result.length < length) {
69
+ result.push(allChars[randomInt(allChars.length)]!);
70
+ }
71
+
72
+ for (let i = result.length - 1; i > 0; i--) {
73
+ const j = randomInt(i + 1);
74
+ const current = result[i]!;
75
+ result[i] = result[j]!;
76
+ result[j] = current;
77
+ }
78
+
79
+ return result.slice(0, length).join('');
80
+ }
81
+
82
+ function randomInt(max: number): number {
83
+ return Math.floor(Math.random() * max);
84
+ }
package/src/index.ts ADDED
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env bun
2
+ import * as p from '@clack/prompts';
3
+ import chalk from 'chalk';
4
+ import { ensureKeys } from './keys';
5
+ import { printBanner, pressEnterHint } from './ui';
6
+ import { checkCancel } from './utils';
7
+ import { runCli } from './cli';
8
+ import { addCredentialFlow } from './command-pages/addCredential';
9
+ import { listCredentialsFlow } from './command-pages/listCredentials';
10
+ import { viewCredentialFlow } from './command-pages/viewCredential';
11
+ import { generatePasswordFlow } from './command-pages/generatePassword';
12
+ import { manageUsersFlow } from './command-pages/manageUsers';
13
+
14
+ async function main() {
15
+ const cliArgs = process.argv.slice(2);
16
+
17
+ if (cliArgs.length > 0) {
18
+ const handled = await runCli(cliArgs);
19
+ if (handled) return;
20
+ }
21
+
22
+ printBanner();
23
+ await ensureKeys();
24
+
25
+ while (true) {
26
+ printBanner();
27
+
28
+ const action = checkCancel(
29
+ await p.select({
30
+ message: 'What would you like to do?',
31
+ options: [
32
+ { value: 'add', label: 'Add Credential' },
33
+ { value: 'list', label: 'List All Credentials' },
34
+ { value: 'view', label: 'View Credential' },
35
+ { value: 'generate', label: 'Generate Password' },
36
+ { value: 'users', label: 'Manage Users' },
37
+ { value: 'exit', label: 'Exit' },
38
+ ],
39
+ }),
40
+ );
41
+
42
+ if (action === 'exit') {
43
+ p.outro(chalk.greenBright.bold('See you later 👋'));
44
+ break;
45
+ }
46
+
47
+ try {
48
+ if (action === 'add') await addCredentialFlow();
49
+ else if (action === 'list') await listCredentialsFlow();
50
+ else if (action === 'view') await viewCredentialFlow();
51
+ else if (action === 'generate') await generatePasswordFlow();
52
+ else if (action === 'users') await manageUsersFlow();
53
+ } catch (err) {
54
+ p.log.error(chalk.red(String(err instanceof Error ? err.message : err)));
55
+ }
56
+
57
+ await p.text({ message: pressEnterHint(), defaultValue: '' });
58
+ }
59
+ }
60
+
61
+ main().catch((err) => {
62
+ console.error(chalk.red(err));
63
+ process.exit(1);
64
+ });