@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.
package/src/keys.ts ADDED
@@ -0,0 +1,167 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import * as p from '@clack/prompts';
3
+ import chalk from 'chalk';
4
+ import {
5
+ PRIVATE_KEY_PATH,
6
+ PUBLIC_KEY_PATH,
7
+ CONFIG_FILE,
8
+ ensureAppDir,
9
+ } from './config';
10
+ import type { AppConfig } from './types';
11
+
12
+ export function keysExist(): boolean {
13
+ return existsSync(PRIVATE_KEY_PATH) && existsSync(PUBLIC_KEY_PATH);
14
+ }
15
+
16
+ export function loadConfig(): AppConfig {
17
+ if (!existsSync(CONFIG_FILE)) return { protected: false };
18
+ try {
19
+ return JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
20
+ } catch {
21
+ return { protected: false };
22
+ }
23
+ }
24
+
25
+ export function saveConfig(cfg: AppConfig) {
26
+ writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2));
27
+ }
28
+
29
+ async function runOpenssl(args: string[], env?: Record<string, string>) {
30
+ const proc = Bun.spawn(['openssl', ...args], {
31
+ stdout: 'pipe',
32
+ stderr: 'pipe',
33
+ env: { ...process.env, ...env },
34
+ });
35
+
36
+ const exitCode = await proc.exited;
37
+
38
+ if (exitCode !== 0) {
39
+ const stderr = await new Response(proc.stderr).text();
40
+ throw new Error(`openssl exited with code ${exitCode}: ${stderr.trim()}`);
41
+ }
42
+ }
43
+
44
+ export async function generateKeysFlow() {
45
+ ensureAppDir();
46
+
47
+ p.log.warn(
48
+ chalk.yellow('Key pair not found at ~/.cuy-pwm/keys. Generating one now.'),
49
+ );
50
+
51
+ const useProtect = await p.confirm({
52
+ message: 'Protect private key with a passphrase?',
53
+ initialValue: true,
54
+ });
55
+
56
+ if (p.isCancel(useProtect)) {
57
+ p.cancel('Cancelled.');
58
+ process.exit(0);
59
+ }
60
+
61
+ let passphrase: string | undefined;
62
+
63
+ if (useProtect) {
64
+ const pass1 = await p.password({ message: 'Enter passphrase' });
65
+ if (p.isCancel(pass1)) {
66
+ p.cancel('Cancelled.');
67
+ process.exit(0);
68
+ }
69
+
70
+ const pass2 = await p.password({ message: 'Confirm passphrase' });
71
+ if (p.isCancel(pass2)) {
72
+ p.cancel('Cancelled.');
73
+ process.exit(0);
74
+ }
75
+
76
+ if (pass1 !== pass2) {
77
+ p.log.error(
78
+ chalk.red('Passphrases do not match. Please restart the application.'),
79
+ );
80
+ process.exit(1);
81
+ }
82
+
83
+ passphrase = pass1;
84
+ }
85
+
86
+ const s = p.spinner();
87
+ s.start('Generating RSA 4096-bit key pair via openssl...');
88
+
89
+ try {
90
+ if (passphrase) {
91
+ await runOpenssl(
92
+ [
93
+ 'genrsa',
94
+ '-aes256',
95
+ '-passout',
96
+ 'env:PWM_PASSPHRASE',
97
+ '-out',
98
+ PRIVATE_KEY_PATH,
99
+ '4096',
100
+ ],
101
+ { PWM_PASSPHRASE: passphrase },
102
+ );
103
+
104
+ await runOpenssl(
105
+ [
106
+ 'rsa',
107
+ '-in',
108
+ PRIVATE_KEY_PATH,
109
+ '-passin',
110
+ 'env:PWM_PASSPHRASE',
111
+ '-pubout',
112
+ '-out',
113
+ PUBLIC_KEY_PATH,
114
+ ],
115
+ { PWM_PASSPHRASE: passphrase },
116
+ );
117
+ } else {
118
+ await runOpenssl(['genrsa', '-out', PRIVATE_KEY_PATH, '4096']);
119
+ await runOpenssl([
120
+ 'rsa',
121
+ '-in',
122
+ PRIVATE_KEY_PATH,
123
+ '-pubout',
124
+ '-out',
125
+ PUBLIC_KEY_PATH,
126
+ ]);
127
+ }
128
+
129
+ saveConfig({ protected: !!passphrase });
130
+
131
+ s.stop('RSA key pair generated successfully.');
132
+ p.log.success(chalk.green(`Private key: ${PRIVATE_KEY_PATH}`));
133
+ p.log.success(chalk.green(`Public key : ${PUBLIC_KEY_PATH}`));
134
+ } catch (err) {
135
+ s.stop('Failed to generate key.');
136
+ p.log.error(chalk.red(String(err)));
137
+ process.exit(1);
138
+ }
139
+ }
140
+
141
+ export async function ensureKeys() {
142
+ if (!keysExist()) {
143
+ await generateKeysFlow();
144
+ }
145
+ }
146
+
147
+ export function readPublicKey(): string {
148
+ return readFileSync(PUBLIC_KEY_PATH, 'utf8');
149
+ }
150
+
151
+ export function readPrivateKeyPem(): string {
152
+ return readFileSync(PRIVATE_KEY_PATH, 'utf8');
153
+ }
154
+
155
+ export async function getPrivateKeyPassphraseIfNeeded(): Promise<
156
+ string | undefined
157
+ > {
158
+ const cfg = loadConfig();
159
+ if (!cfg.protected) return undefined;
160
+
161
+ const pass = await p.password({ message: 'Enter private key passphrase' });
162
+ if (p.isCancel(pass)) {
163
+ p.cancel('Cancelled.');
164
+ process.exit(0);
165
+ }
166
+ return pass;
167
+ }
package/src/store.ts ADDED
@@ -0,0 +1,74 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { SOURCE_FILE, ensureAppDir } from './config';
4
+ import type { PasswordEntry, CredUser } from './types';
5
+
6
+ interface DbStore {
7
+ cred_users: CredUser[];
8
+ cred_entries: PasswordEntry[];
9
+ }
10
+
11
+ function loadDb(): DbStore {
12
+ ensureAppDir();
13
+ if (!existsSync(SOURCE_FILE)) return { cred_users: [], cred_entries: [] };
14
+ try {
15
+ return JSON.parse(readFileSync(SOURCE_FILE, 'utf8'));
16
+ } catch {
17
+ return { cred_users: [], cred_entries: [] };
18
+ }
19
+ }
20
+
21
+ function saveDb(db: DbStore) {
22
+ ensureAppDir();
23
+ writeFileSync(SOURCE_FILE, JSON.stringify(db, null, 2));
24
+ }
25
+
26
+ export function loadEntries(): PasswordEntry[] {
27
+ return loadDb().cred_entries;
28
+ }
29
+
30
+ export function addEntry(entry: PasswordEntry) {
31
+ const db = loadDb();
32
+ db.cred_entries.push(entry);
33
+ saveDb(db);
34
+ }
35
+
36
+ export function deleteEntry(id: string) {
37
+ const db = loadDb();
38
+ db.cred_entries = db.cred_entries.filter((e) => e.id !== id);
39
+ saveDb(db);
40
+ }
41
+
42
+ export function updateEntry(updated: PasswordEntry) {
43
+ const db = loadDb();
44
+ db.cred_entries = db.cred_entries.map((e) =>
45
+ e.id === updated.id ? updated : e,
46
+ );
47
+ saveDb(db);
48
+ }
49
+
50
+ export function loadUsers(): CredUser[] {
51
+ return loadDb().cred_users;
52
+ }
53
+
54
+ export function addUser(user: CredUser) {
55
+ const db = loadDb();
56
+ db.cred_users.push(user);
57
+ saveDb(db);
58
+ }
59
+
60
+ export function updateUser(updated: CredUser) {
61
+ const db = loadDb();
62
+ db.cred_users = db.cred_users.map((u) => (u.id === updated.id ? updated : u));
63
+ saveDb(db);
64
+ }
65
+
66
+ export function deleteUser(id: string) {
67
+ const db = loadDb();
68
+ db.cred_users = db.cred_users.filter((u) => u.id !== id);
69
+ saveDb(db);
70
+ }
71
+
72
+ export function newId(): string {
73
+ return randomUUID();
74
+ }
package/src/types.ts ADDED
@@ -0,0 +1,43 @@
1
+ export type CredentialType =
2
+ 'github' | 'gitlab' | 'gmail' | 'bank' | 'website' | 'ssh_cred' | 'ssh_key';
3
+
4
+ export interface CredUser {
5
+ id: string;
6
+ name: string;
7
+ }
8
+
9
+ export interface BaseEntry {
10
+ id: string;
11
+ type: CredentialType;
12
+ source: string;
13
+ description: string;
14
+ createdAt: string;
15
+ cred_user_id: string | null;
16
+ }
17
+
18
+ export interface SimpleCredentialEntry extends BaseEntry {
19
+ type: 'github' | 'gitlab' | 'gmail' | 'website' | 'bank';
20
+ username: string;
21
+ encrypted_password: string;
22
+ extra?: Record<string, string>;
23
+ }
24
+
25
+ export interface SshCredEntry extends BaseEntry {
26
+ type: 'ssh_cred';
27
+ username: string;
28
+ encrypted_password: string;
29
+ host: string;
30
+ port: number;
31
+ }
32
+
33
+ export interface SshKeyEntry extends BaseEntry {
34
+ type: 'ssh_key';
35
+ encrypted_private_key: string;
36
+ public_key: string;
37
+ }
38
+
39
+ export type PasswordEntry = SimpleCredentialEntry | SshCredEntry | SshKeyEntry;
40
+
41
+ export interface AppConfig {
42
+ protected: boolean;
43
+ }
package/src/ui.ts ADDED
@@ -0,0 +1,212 @@
1
+ import chalk from 'chalk';
2
+ import boxen from 'boxen';
3
+ import Table from 'cli-table3';
4
+ import type { PasswordEntry, CredentialType, CredUser } from './types';
5
+
6
+ function hexToRgb(hex: string): [number, number, number] {
7
+ const clean = hex.replace('#', '');
8
+ const num = parseInt(clean, 16);
9
+ return [(num >> 16) & 255, (num >> 8) & 255, num & 255];
10
+ }
11
+
12
+ function lerp(a: number, b: number, t: number): number {
13
+ return Math.round(a + (b - a) * t);
14
+ }
15
+
16
+ function gradientText(text: string, fromHex: string, toHex: string): string {
17
+ const [r1, g1, b1] = hexToRgb(fromHex);
18
+ const [r2, g2, b2] = hexToRgb(toHex);
19
+ const chars = [...text];
20
+
21
+ return chars
22
+ .map((ch, i) => {
23
+ if (ch === ' ') return ch;
24
+ const t = chars.length <= 1 ? 0 : i / (chars.length - 1);
25
+ const r = lerp(r1, r2, t);
26
+ const g = lerp(g1, g2, t);
27
+ const b = lerp(b1, b2, t);
28
+ return chalk.rgb(r, g, b)(ch);
29
+ })
30
+ .join('');
31
+ }
32
+
33
+ const LOGO_LINES = [
34
+ ' ██████╗██╗ ██╗██╗ ██╗ ██████╗ ██╗ ██╗███╗ ███╗',
35
+ '██╔════╝██║ ██║╚██╗ ██╔╝ ██╔══██╗██║ ██║████╗ ████║',
36
+ '██║ ██║ ██║ ╚████╔╝ █████╗██████╔╝██║ █╗ ██║██╔████╔██║',
37
+ '██║ ██║ ██║ ╚██╔╝ ╚════╝██╔═══╝ ██║███╗██║██║╚██╔╝██║',
38
+ '╚██████╗╚██████╔╝ ██║ ██║ ╚███╔███╔╝██║ ╚═╝ ██║',
39
+ ' ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝',
40
+ ];
41
+
42
+ export function printBanner() {
43
+ console.clear();
44
+
45
+ const logo = LOGO_LINES.map((line) =>
46
+ gradientText(line, '#22d3ee', '#c084fc'),
47
+ ).join('\n');
48
+
49
+ console.log();
50
+ console.log(logo);
51
+ console.log(
52
+ chalk.dim(' 🔐 CLI Password Manager • powered by Bun & OpenSSL'),
53
+ );
54
+ console.log(chalk.dim(' copyright by cuytamvan 2026'));
55
+ console.log(
56
+ chalk.dim(' ────────────────────────────────────────────────────────'),
57
+ );
58
+ console.log();
59
+ }
60
+
61
+ export function divider() {
62
+ console.log(
63
+ chalk.dim('────────────────────────────────────────────────────────'),
64
+ );
65
+ }
66
+
67
+ export function pressEnterHint() {
68
+ return chalk.dim.italic('Press Enter to return to the menu...');
69
+ }
70
+
71
+ export const TYPE_META: Record<
72
+ CredentialType,
73
+ { icon: string; label: string; color: string }
74
+ > = {
75
+ github: { icon: '🐙', label: 'GitHub', color: '#e6edf3' },
76
+ gitlab: { icon: '🦊', label: 'GitLab', color: '#fc6d26' },
77
+ gmail: { icon: '✉️ ', label: 'Gmail', color: '#ea4335' },
78
+ bank: { icon: '🏦', label: 'Bank Account', color: '#facc15' },
79
+ website: { icon: '🌐', label: 'Website', color: '#38bdf8' },
80
+ ssh_cred: { icon: '🖥️ ', label: 'SSH Credential', color: '#34d399' },
81
+ ssh_key: { icon: '🔑', label: 'SSH Key', color: '#a78bfa' },
82
+ };
83
+
84
+ export function typeBadge(type: CredentialType): string {
85
+ const meta = TYPE_META[type];
86
+ return chalk.hex(meta.color).bold(`${meta.icon} ${meta.label}`);
87
+ }
88
+
89
+ export function entryLabel(e: PasswordEntry): string {
90
+ const desc = e.description ? chalk.dim(` — ${e.description}`) : '';
91
+ return `${typeBadge(e.type)} ${chalk.whiteBright(e.source)}${desc}`;
92
+ }
93
+
94
+ export function credentialTypeOptions() {
95
+ return (Object.keys(TYPE_META) as CredentialType[]).map((type) => ({
96
+ value: type,
97
+ label: typeBadge(type),
98
+ }));
99
+ }
100
+
101
+ export function detailBox(entry: PasswordEntry, body: string): string {
102
+ const meta = TYPE_META[entry.type];
103
+ return boxen(body, {
104
+ title: `${meta.icon} ${entry.source}`,
105
+ titleAlignment: 'center',
106
+ padding: 1,
107
+ margin: { top: 1, bottom: 1, left: 0, right: 0 },
108
+ borderStyle: 'round',
109
+ borderColor: meta.color,
110
+ });
111
+ }
112
+
113
+ export function passwordBox(password: string, title = '🎲 Password'): string {
114
+ return boxen(chalk.bold.hex('#facc15')(password), {
115
+ title,
116
+ titleAlignment: 'center',
117
+ padding: 1,
118
+ margin: { top: 1, bottom: 1, left: 0, right: 0 },
119
+ borderStyle: 'double',
120
+ borderColor: 'yellow',
121
+ });
122
+ }
123
+
124
+ export function dangerBox(title: string, body: string): string {
125
+ return boxen(body, {
126
+ title,
127
+ titleAlignment: 'center',
128
+ padding: 1,
129
+ margin: { top: 1, bottom: 1, left: 0, right: 0 },
130
+ borderStyle: 'round',
131
+ borderColor: 'red',
132
+ });
133
+ }
134
+
135
+ export function successBox(title: string, body: string): string {
136
+ return boxen(body, {
137
+ title,
138
+ titleAlignment: 'center',
139
+ padding: 1,
140
+ margin: { top: 1, bottom: 1, left: 0, right: 0 },
141
+ borderStyle: 'round',
142
+ borderColor: 'green',
143
+ });
144
+ }
145
+
146
+ function truncate(text: string, max: number): string {
147
+ if (!text) return chalk.dim('-');
148
+ return text.length > max ? text.slice(0, max - 1) + '…' : text;
149
+ }
150
+
151
+ function entryUsernameCol(e: PasswordEntry): string {
152
+ if (e.type === 'ssh_key') return chalk.dim('(key pair)');
153
+ if (e.type === 'ssh_cred')
154
+ return `${e.username}${chalk.dim('@' + e.host + ':' + e.port)}`;
155
+ return e.username;
156
+ }
157
+
158
+ export function entriesTable(
159
+ entries: PasswordEntry[],
160
+ users?: CredUser[],
161
+ showId: boolean = true,
162
+ ): string {
163
+ const userMap = new Map((users ?? []).map((u) => [u.id, u.name]));
164
+ const showUser = users !== undefined;
165
+
166
+ const head = [
167
+ ...(showId ? [chalk.bold.cyan('ID')] : []),
168
+ chalk.bold.cyan('Type'),
169
+ chalk.bold.cyan('Source'),
170
+ chalk.bold.cyan('Username / Info'),
171
+ chalk.bold.cyan('Description'),
172
+ chalk.bold.cyan('Created'),
173
+ ];
174
+ if (showUser) head.push(chalk.bold.cyan('User'));
175
+
176
+ const table = new Table({
177
+ head,
178
+ style: { head: [], border: ['dim'] },
179
+ wordWrap: true,
180
+ });
181
+
182
+ entries.forEach((e) => {
183
+ const meta = TYPE_META[e.type];
184
+ const typeCol = chalk.hex(meta.color).bold(`${meta.icon} ${meta.label}`);
185
+ const createdCol = chalk.dim(
186
+ new Date(e.createdAt).toLocaleDateString('id-ID', {
187
+ day: '2-digit',
188
+ month: 'short',
189
+ year: 'numeric',
190
+ }),
191
+ );
192
+
193
+ const row = [
194
+ ...(showId ? [chalk.whiteBright(e.id)] : []),
195
+ typeCol,
196
+ chalk.whiteBright(truncate(e.source, 24)),
197
+ entryUsernameCol(e),
198
+ truncate(e.description, 28),
199
+ createdCol,
200
+ ];
201
+ if (showUser) {
202
+ const userName = e.cred_user_id
203
+ ? (userMap.get(e.cred_user_id) ?? chalk.dim('?'))
204
+ : chalk.dim('-');
205
+ row.push(chalk.whiteBright(userName));
206
+ }
207
+ table.push(row);
208
+ });
209
+
210
+ const header = chalk.dim(`📋 Total: ${entries.length} credential(s) saved\n`);
211
+ return header + table.toString();
212
+ }
@@ -0,0 +1,10 @@
1
+ import * as p from '@clack/prompts';
2
+ import chalk from 'chalk';
3
+
4
+ export function checkCancel<T>(value: T | symbol): T {
5
+ if (p.isCancel(value)) {
6
+ p.cancel(chalk.red('Cancelled.'));
7
+ process.exit(0);
8
+ }
9
+ return value as T;
10
+ }
@@ -0,0 +1,5 @@
1
+ export { checkCancel } from './checkCancel';
2
+ export { resolvePath } from './resolvePath';
3
+ export { passwordOrGenerate } from './passwordOrGenerate';
4
+ export { pickFilteredEntries } from './userFilter';
5
+ export type { FilteredEntries } from './userFilter';
@@ -0,0 +1,31 @@
1
+ import * as p from '@clack/prompts';
2
+ import chalk from 'chalk';
3
+ import { promptGeneratePassword } from '../generator';
4
+ import { passwordBox } from '../ui';
5
+ import { checkCancel } from './checkCancel';
6
+
7
+ export async function passwordOrGenerate(): Promise<string> {
8
+ const choice = checkCancel(
9
+ await p.select({
10
+ message: 'How would you like to set the password?',
11
+ options: [
12
+ { value: 'manual', label: `${chalk.blue('⌨️ ')} Enter manually` },
13
+ { value: 'generate', label: `${chalk.yellow('🎲')} Generate password` },
14
+ ],
15
+ }),
16
+ );
17
+
18
+ if (choice === 'generate') {
19
+ const generated = await promptGeneratePassword();
20
+ if (!generated) {
21
+ p.log.warn(
22
+ chalk.yellow('Generation cancelled, falling back to manual input.'),
23
+ );
24
+ return checkCancel(await p.password({ message: 'Password' }));
25
+ }
26
+ console.log(passwordBox(generated, '🎲 Generated password'));
27
+ return generated;
28
+ }
29
+
30
+ return checkCancel(await p.password({ message: 'Password' }));
31
+ }
@@ -0,0 +1,6 @@
1
+ export function resolvePath(path: string): string {
2
+ if (path.startsWith('~')) {
3
+ return path.replace('~', process.env.HOME || process.env.USERPROFILE || '');
4
+ }
5
+ return path;
6
+ }
@@ -0,0 +1,37 @@
1
+ import * as p from '@clack/prompts';
2
+ import chalk from 'chalk';
3
+ import { loadEntries, loadUsers } from '../store';
4
+ import type { PasswordEntry, CredUser } from '../types';
5
+ import { checkCancel } from './checkCancel';
6
+
7
+ export interface FilteredEntries {
8
+ /** Entries after applying the user filter (may equal allEntries if no filter). */
9
+ entries: PasswordEntry[];
10
+ users: CredUser[];
11
+ /** Total entries before filtering — used to differentiate "no data" vs "empty filter". */
12
+ total: number;
13
+ }
14
+
15
+ export async function pickFilteredEntries(): Promise<FilteredEntries> {
16
+ const allEntries = loadEntries();
17
+ const users = loadUsers();
18
+ let entries = allEntries;
19
+
20
+ if (users.length) {
21
+ const filterChoice = checkCancel(
22
+ await p.select({
23
+ message: 'Filter by user',
24
+ options: [
25
+ { value: '__all__', label: chalk.dim('All Users') },
26
+ ...users.map((u) => ({ value: u.id, label: u.name })),
27
+ ],
28
+ }),
29
+ ) as string;
30
+
31
+ if (filterChoice !== '__all__') {
32
+ entries = allEntries.filter((e) => e.cred_user_id === filterChoice);
33
+ }
34
+ }
35
+
36
+ return { entries, users, total: allEntries.length };
37
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "compilerOptions": {
3
+ // Environment setup & latest features
4
+ "lib": ["ESNext"],
5
+ "target": "ESNext",
6
+ "module": "Preserve",
7
+ "moduleDetection": "force",
8
+ "jsx": "react-jsx",
9
+ "allowJs": true,
10
+ "types": ["bun"],
11
+
12
+ // Bundler mode
13
+ "moduleResolution": "bundler",
14
+ "allowImportingTsExtensions": true,
15
+ "verbatimModuleSyntax": true,
16
+ "noEmit": true,
17
+
18
+ // Best practices
19
+ "strict": true,
20
+ "skipLibCheck": true,
21
+ "noFallthroughCasesInSwitch": true,
22
+ "noUncheckedIndexedAccess": true,
23
+ "noImplicitOverride": true,
24
+
25
+ // Some stricter flags (disabled by default)
26
+ "noUnusedLocals": false,
27
+ "noUnusedParameters": false,
28
+ "noPropertyAccessFromIndexSignature": false
29
+ }
30
+ }