vaulter-cli 0.1.3 → 0.2.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/README.md CHANGED
@@ -29,6 +29,12 @@ vaulter add my-openai-key
29
29
 
30
30
  # Remove a key
31
31
  vaulter remove my-openai-key
32
+
33
+ # Generate a .env file from your vault
34
+ vaulter make .env
35
+
36
+ # Upload a local .env file to your vault
37
+ vaulter save .env
32
38
  ```
33
39
 
34
40
  ## Commands
@@ -40,9 +46,40 @@ vaulter remove my-openai-key
40
46
  | `vaulter ls` | List all API keys in your vault |
41
47
  | `vaulter add <name>` | Add a new API key to your vault |
42
48
  | `vaulter remove <name-or-id>` | Remove an API key from your vault |
49
+ | `vaulter make [file]` | Generate a .env file from your vault keys |
50
+ | `vaulter save [file]` | Upload a local .env file to your vault |
43
51
  | `vaulter web-app` | Open the Vaulter web app in your browser |
44
52
  | `vaulter help` | Show all available commands |
45
53
 
54
+ ## .env Support
55
+
56
+ ### `vaulter make [file]`
57
+
58
+ Generate a `.env` file from keys stored in your vault. You select which keys to include via an interactive checkbox. Key names are automatically converted to `UPPER_SNAKE_CASE` (e.g. "My Stripe Key" becomes `MY_STRIPE_KEY`). The output file is written with `0600` permissions.
59
+
60
+ ```bash
61
+ # Write to .env in current directory
62
+ vaulter make
63
+
64
+ # Write to a specific file
65
+ vaulter make .env.local
66
+
67
+ # Write to a different directory
68
+ vaulter make .env -o ./config
69
+ ```
70
+
71
+ ### `vaulter save [file]`
72
+
73
+ Upload a local `.env` file to your vault. The file is **parsed entirely on your machine** — only the extracted key names and values are sent to the API. If any keys already exist in your vault, you'll be prompted to skip or overwrite each one individually.
74
+
75
+ ```bash
76
+ # Upload .env from current directory
77
+ vaulter save
78
+
79
+ # Upload a specific file
80
+ vaulter save .env.production
81
+ ```
82
+
46
83
  ## Authentication
47
84
 
48
85
  Vaulter uses browser-based device auth. Running `vaulter sign-in` opens your browser where you log in, and the CLI receives a token automatically. Credentials are stored locally at `~/.vaulter/credentials.json` with restricted file permissions.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vaulter-cli",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "CLI tool for Vaulter - Secure API Key Manager",
5
5
  "author": "faris-sait",
6
6
  "repository": {
@@ -1,7 +1,7 @@
1
1
  import inquirer from 'inquirer';
2
2
  import ora from 'ora';
3
3
  import { apiFetch } from '../lib/api.js';
4
- import { success, error, info, purple } from '../lib/ui.js';
4
+ import { success, error, info, purple, tip } from '../lib/ui.js';
5
5
 
6
6
  export async function addKey(name) {
7
7
  console.log('');
@@ -38,6 +38,7 @@ export async function addKey(name) {
38
38
  spinner.succeed('Key added to vault!');
39
39
  console.log('');
40
40
  success(`"${name}" has been securely stored.`);
41
+ tip('Run `vaulter make .env` to include this key in a .env file.');
41
42
  console.log('');
42
43
  } catch (err) {
43
44
  spinner.fail('Failed to add key');
@@ -21,6 +21,8 @@ export async function showHelp() {
21
21
  { name: 'ls', desc: 'List all API keys in your vault' },
22
22
  { name: 'add <name>', desc: 'Add a new API key to your vault' },
23
23
  { name: 'remove <name-or-id>', desc: 'Remove an API key from your vault' },
24
+ { name: 'make [file]', desc: 'Generate a .env file from your vault keys' },
25
+ { name: 'save [file]', desc: 'Upload a local .env file to your vault' },
24
26
  { name: 'web-app', desc: 'Open the Vaulter web app in your browser' },
25
27
  { name: 'help', desc: 'Show this help message' },
26
28
  ];
@@ -2,7 +2,7 @@ import Table from 'cli-table3';
2
2
  import chalk from 'chalk';
3
3
  import ora from 'ora';
4
4
  import { apiFetch } from '../lib/api.js';
5
- import { purple, dim, error } from '../lib/ui.js';
5
+ import { purple, dim, error, tip } from '../lib/ui.js';
6
6
 
7
7
  export async function listKeys() {
8
8
  const spinner = ora({ text: 'Fetching keys...', color: 'magenta' }).start();
@@ -58,6 +58,7 @@ export async function listKeys() {
58
58
  console.log(table.toString());
59
59
  console.log('');
60
60
  console.log(dim(` ${keys.length} key(s) in your vault`));
61
+ tip('Run `vaulter make .env` to export keys to a .env file.');
61
62
  console.log('');
62
63
  } catch (err) {
63
64
  spinner.fail('Failed to fetch keys');
@@ -0,0 +1,133 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import inquirer from 'inquirer';
4
+ import ora from 'ora';
5
+ import { apiFetch } from '../lib/api.js';
6
+ import { success, error, warn, dim, purple, tip } from '../lib/ui.js';
7
+
8
+ function toEnvVarName(name) {
9
+ return name
10
+ .replace(/[\s\-\.]+/g, '_')
11
+ .toUpperCase()
12
+ .replace(/[^A-Z0-9_]/g, '')
13
+ .replace(/_+/g, '_')
14
+ .replace(/^_|_$/g, '');
15
+ }
16
+
17
+ export async function makeEnv(filename, options) {
18
+ const outputDir = options.output ? path.resolve(options.output) : process.cwd();
19
+ const file = filename || '.env';
20
+ const outputPath = path.join(outputDir, file);
21
+
22
+ // Check if output directory exists
23
+ if (options.output && !fs.existsSync(outputDir)) {
24
+ console.log('');
25
+ error(`Directory not found: ${outputDir}`);
26
+ console.log('');
27
+ return;
28
+ }
29
+
30
+ // Check if file already exists
31
+ if (fs.existsSync(outputPath)) {
32
+ console.log('');
33
+ const { overwrite } = await inquirer.prompt([
34
+ {
35
+ type: 'confirm',
36
+ name: 'overwrite',
37
+ message: purple(`${outputPath} already exists. Overwrite?`),
38
+ default: false,
39
+ },
40
+ ]);
41
+ if (!overwrite) {
42
+ warn('Cancelled.');
43
+ console.log('');
44
+ return;
45
+ }
46
+ }
47
+
48
+ // Fetch all keys
49
+ const spinner = ora({ text: 'Fetching vault keys...', color: 'magenta' }).start();
50
+
51
+ let keys;
52
+ try {
53
+ const data = await apiFetch('/api/keys');
54
+ keys = data.keys || [];
55
+ } catch (err) {
56
+ spinner.fail('Failed to fetch keys');
57
+ error(err.message);
58
+ return;
59
+ }
60
+
61
+ spinner.stop();
62
+
63
+ if (keys.length === 0) {
64
+ console.log('');
65
+ warn('No keys in your vault. Run `vaulter add <name>` to add one.');
66
+ console.log('');
67
+ return;
68
+ }
69
+
70
+ // Interactive checkbox to select keys
71
+ console.log('');
72
+ const { selectedKeys } = await inquirer.prompt([
73
+ {
74
+ type: 'checkbox',
75
+ name: 'selectedKeys',
76
+ message: purple('Select keys to include in .env:'),
77
+ choices: keys.map((k) => ({
78
+ name: `${k.name} ${dim(`(${k.masked_key})`)}`,
79
+ value: k,
80
+ checked: true,
81
+ })),
82
+ },
83
+ ]);
84
+
85
+ if (selectedKeys.length === 0) {
86
+ console.log('');
87
+ warn('No keys selected.');
88
+ console.log('');
89
+ return;
90
+ }
91
+
92
+ // Decrypt each selected key
93
+ const decryptSpinner = ora({ text: `Decrypting ${selectedKeys.length} key(s)...`, color: 'magenta' }).start();
94
+
95
+ const lines = [];
96
+ let failed = 0;
97
+
98
+ for (const key of selectedKeys) {
99
+ try {
100
+ const data = await apiFetch(`/api/keys/${key.id}?decrypt=true`);
101
+ const envName = toEnvVarName(key.name);
102
+ lines.push(`${envName}=${data.decrypted_key}`);
103
+ } catch (err) {
104
+ failed++;
105
+ decryptSpinner.text = `Decrypting... (failed: ${key.name})`;
106
+ }
107
+ }
108
+
109
+ decryptSpinner.stop();
110
+
111
+ if (lines.length === 0) {
112
+ console.log('');
113
+ error('All keys failed to decrypt.');
114
+ console.log('');
115
+ return;
116
+ }
117
+
118
+ // Write .env file with restrictive permissions
119
+ try {
120
+ fs.writeFileSync(outputPath, lines.join('\n') + '\n', { mode: 0o600 });
121
+ } catch (err) {
122
+ error(`Failed to write file: ${err.message}`);
123
+ return;
124
+ }
125
+
126
+ console.log('');
127
+ success(`${lines.length} key(s) written to ${outputPath}`);
128
+ if (failed > 0) {
129
+ warn(`${failed} key(s) failed to decrypt.`);
130
+ }
131
+ tip('Run `vaulter save .env` to upload a .env file back to your vault.');
132
+ console.log('');
133
+ }
@@ -0,0 +1,128 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import inquirer from 'inquirer';
4
+ import ora from 'ora';
5
+ import { apiFetch } from '../lib/api.js';
6
+ import { parseEnvFile } from '../lib/env-parser.js';
7
+ import { success, error, warn, dim, purple, tip } from '../lib/ui.js';
8
+
9
+ export async function saveEnv(filename) {
10
+ const file = filename || '.env';
11
+ const inputPath = path.resolve(file);
12
+
13
+ // Check file exists
14
+ if (!fs.existsSync(inputPath)) {
15
+ console.log('');
16
+ error(`File not found: ${inputPath}`);
17
+ console.log('');
18
+ return;
19
+ }
20
+
21
+ // Read and parse
22
+ const content = fs.readFileSync(inputPath, 'utf-8');
23
+ const { parsedKeys, warnings } = parseEnvFile(content);
24
+
25
+ // Show warnings
26
+ if (warnings.length > 0) {
27
+ console.log('');
28
+ for (const w of warnings) {
29
+ warn(w);
30
+ }
31
+ }
32
+
33
+ if (parsedKeys.length === 0) {
34
+ console.log('');
35
+ error('No valid keys found in the file.');
36
+ console.log('');
37
+ return;
38
+ }
39
+
40
+ console.log('');
41
+ success(`Found ${parsedKeys.length} key(s) in ${file}`);
42
+
43
+ // Fetch existing vault keys to detect duplicates
44
+ const spinner = ora({ text: 'Checking for duplicates...', color: 'magenta' }).start();
45
+
46
+ let existingKeys;
47
+ try {
48
+ const data = await apiFetch('/api/keys');
49
+ existingKeys = data.keys || [];
50
+ } catch (err) {
51
+ spinner.fail('Failed to fetch existing keys');
52
+ error(err.message);
53
+ return;
54
+ }
55
+
56
+ spinner.stop();
57
+
58
+ const existingNames = new Set(existingKeys.map((k) => k.name));
59
+ const duplicates = parsedKeys.filter((k) => existingNames.has(k.name));
60
+ const overwriteKeys = [];
61
+
62
+ // Per-key duplicate resolution
63
+ if (duplicates.length > 0) {
64
+ console.log('');
65
+ warn(`${duplicates.length} key(s) already exist in your vault:`);
66
+ console.log('');
67
+
68
+ for (const dup of duplicates) {
69
+ const { action } = await inquirer.prompt([
70
+ {
71
+ type: 'list',
72
+ name: 'action',
73
+ message: purple(`"${dup.name}" already exists. What do you want to do?`),
74
+ choices: [
75
+ { name: 'Skip', value: 'skip' },
76
+ { name: 'Overwrite', value: 'overwrite' },
77
+ ],
78
+ },
79
+ ]);
80
+
81
+ if (action === 'overwrite') {
82
+ overwriteKeys.push(dup.name);
83
+ }
84
+ }
85
+ }
86
+
87
+ // Filter: keep non-duplicates + overwrite choices
88
+ const keysToImport = parsedKeys.filter(
89
+ (k) => !existingNames.has(k.name) || overwriteKeys.includes(k.name)
90
+ );
91
+
92
+ if (keysToImport.length === 0) {
93
+ console.log('');
94
+ warn('All keys were skipped. Nothing to upload.');
95
+ console.log('');
96
+ return;
97
+ }
98
+
99
+ // Upload
100
+ const uploadSpinner = ora({ text: `Uploading ${keysToImport.length} key(s)...`, color: 'magenta' }).start();
101
+
102
+ try {
103
+ const result = await apiFetch('/api/keys/bulk', {
104
+ method: 'POST',
105
+ body: JSON.stringify({ keys: keysToImport, overwriteKeys }),
106
+ });
107
+
108
+ uploadSpinner.succeed('Upload complete!');
109
+ console.log('');
110
+
111
+ const imported = result.imported || keysToImport.length;
112
+ const overwritten = overwriteKeys.length;
113
+ const failed = result.failed || 0;
114
+
115
+ success(`${imported} key(s) saved to vault.`);
116
+ if (overwritten > 0) {
117
+ warn(`${overwritten} key(s) overwritten.`);
118
+ }
119
+ if (failed > 0) {
120
+ error(`${failed} key(s) failed.`);
121
+ }
122
+ tip('Run `vaulter ls` to see all your vault keys.');
123
+ console.log('');
124
+ } catch (err) {
125
+ uploadSpinner.fail('Failed to upload keys');
126
+ error(err.message);
127
+ }
128
+ }
package/src/index.js CHANGED
@@ -6,6 +6,8 @@ import { signOut } from './commands/sign-out.js';
6
6
  import { listKeys } from './commands/ls.js';
7
7
  import { addKey } from './commands/add.js';
8
8
  import { removeKey } from './commands/remove.js';
9
+ import { makeEnv } from './commands/make.js';
10
+ import { saveEnv } from './commands/save.js';
9
11
  import { openWebApp } from './commands/web-app.js';
10
12
  import { showHelp } from './commands/help.js';
11
13
  import { printLogo } from './assets/logo.js';
@@ -65,6 +67,29 @@ program
65
67
  await removeKey(nameOrId);
66
68
  });
67
69
 
70
+ program
71
+ .command('make [filename]')
72
+ .description('Generate a .env file from your vault keys')
73
+ .option('-o, --output <dir>', 'Output directory')
74
+ .action(async (filename, options) => {
75
+ if (!isAuthenticated()) {
76
+ error('Not authenticated. Run `vaulter sign-in` first.');
77
+ process.exit(1);
78
+ }
79
+ await makeEnv(filename, options);
80
+ });
81
+
82
+ program
83
+ .command('save [filename]')
84
+ .description('Upload a local .env file to your vault')
85
+ .action(async (filename) => {
86
+ if (!isAuthenticated()) {
87
+ error('Not authenticated. Run `vaulter sign-in` first.');
88
+ process.exit(1);
89
+ }
90
+ await saveEnv(filename);
91
+ });
92
+
68
93
  program
69
94
  .command('web-app')
70
95
  .description('Open the Vaulter web app in your browser')
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Parse a .env file content into key-value pairs.
3
+ * Ported from the web app's parseEnvFile (app/page.js).
4
+ */
5
+ export function parseEnvFile(content) {
6
+ const lines = content.split('\n');
7
+ const parsedKeys = [];
8
+ const warnings = [];
9
+
10
+ lines.forEach((line, index) => {
11
+ const trimmedLine = line.trim();
12
+
13
+ if (!trimmedLine) {
14
+ return;
15
+ }
16
+
17
+ if (trimmedLine.startsWith('#')) {
18
+ warnings.push(`Line ${index + 1}: Skipped comment "${trimmedLine.substring(0, 50)}${trimmedLine.length > 50 ? '...' : ''}"`);
19
+ return;
20
+ }
21
+
22
+ const equalIndex = trimmedLine.indexOf('=');
23
+ if (equalIndex === -1) {
24
+ warnings.push(`Line ${index + 1}: Invalid format (no = found) "${trimmedLine.substring(0, 30)}..."`);
25
+ return;
26
+ }
27
+
28
+ const name = trimmedLine.substring(0, equalIndex).trim();
29
+ let value = trimmedLine.substring(equalIndex + 1).trim();
30
+
31
+ if (value.length >= 2 &&
32
+ ((value.startsWith('"') && value.endsWith('"')) ||
33
+ (value.startsWith("'") && value.endsWith("'")))) {
34
+ value = value.slice(1, -1);
35
+ }
36
+
37
+ if (!name) {
38
+ warnings.push(`Line ${index + 1}: Empty key name`);
39
+ return;
40
+ }
41
+
42
+ if (!value) {
43
+ warnings.push(`Line ${index + 1}: Empty value for key "${name}"`);
44
+ return;
45
+ }
46
+
47
+ parsedKeys.push({ name, apiKey: value });
48
+ });
49
+
50
+ // Deduplicate — keep last occurrence
51
+ const nameCount = {};
52
+ parsedKeys.forEach(pk => {
53
+ nameCount[pk.name] = (nameCount[pk.name] || 0) + 1;
54
+ });
55
+ const internalDuplicates = Object.keys(nameCount).filter(name => nameCount[name] > 1);
56
+ if (internalDuplicates.length > 0) {
57
+ warnings.push(`Duplicate keys in file (last value will be used): ${internalDuplicates.join(', ')}`);
58
+ const seen = new Set();
59
+ const uniqueKeys = [];
60
+ for (let i = parsedKeys.length - 1; i >= 0; i--) {
61
+ if (!seen.has(parsedKeys[i].name)) {
62
+ seen.add(parsedKeys[i].name);
63
+ uniqueKeys.unshift(parsedKeys[i]);
64
+ }
65
+ }
66
+ return { parsedKeys: uniqueKeys, warnings };
67
+ }
68
+
69
+ return { parsedKeys, warnings };
70
+ }
package/src/lib/ui.js CHANGED
@@ -25,3 +25,7 @@ export function info(msg) {
25
25
  export function warn(msg) {
26
26
  console.log(yellow(' ' + msg));
27
27
  }
28
+
29
+ export function tip(msg) {
30
+ console.log(dim(' Tip: ' + msg));
31
+ }