vaulter-cli 0.1.3 → 0.2.1
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 +37 -0
- package/package.json +1 -1
- package/src/commands/add.js +2 -1
- package/src/commands/help.js +2 -0
- package/src/commands/ls.js +2 -1
- package/src/commands/make.js +133 -0
- package/src/commands/save.js +128 -0
- package/src/index.js +26 -1
- package/src/lib/env-parser.js +70 -0
- package/src/lib/ui.js +4 -0
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
package/src/commands/add.js
CHANGED
|
@@ -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');
|
package/src/commands/help.js
CHANGED
|
@@ -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
|
];
|
package/src/commands/ls.js
CHANGED
|
@@ -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';
|
|
@@ -17,7 +19,7 @@ const program = new Command();
|
|
|
17
19
|
program
|
|
18
20
|
.name('vaulter')
|
|
19
21
|
.description('Vaulter CLI - Secure API Key Manager')
|
|
20
|
-
.version('0.1
|
|
22
|
+
.version('0.2.1')
|
|
21
23
|
.action(async () => {
|
|
22
24
|
await showHelp();
|
|
23
25
|
});
|
|
@@ -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
|
+
}
|