keyra-cli 0.1.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/LICENSE +21 -0
- package/README.md +177 -0
- package/cli/package-lock.json +2575 -0
- package/cli/package.json +36 -0
- package/cli/src/commands/guard.ts +89 -0
- package/cli/src/commands/init.ts +172 -0
- package/cli/src/commands/list.ts +60 -0
- package/cli/src/commands/login.ts +116 -0
- package/cli/src/commands/logout.ts +10 -0
- package/cli/src/commands/pull.ts +94 -0
- package/cli/src/commands/push.ts +118 -0
- package/cli/src/commands/scan.ts +145 -0
- package/cli/src/commands/share.ts +84 -0
- package/cli/src/commands/status.ts +91 -0
- package/cli/src/commands/validate.ts +101 -0
- package/cli/src/index.ts +38 -0
- package/cli/src/lib/api.ts +136 -0
- package/cli/src/lib/config.ts +94 -0
- package/cli/src/lib/encryption.ts +45 -0
- package/cli/src/lib/env-file.ts +67 -0
- package/cli/src/lib/ui.ts +87 -0
- package/cli/src/types.ts +56 -0
- package/cli/tsconfig.json +14 -0
- package/package.json +38 -0
package/cli/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "keyra",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Encrypted .env vault. Sync, share, and never lose an API key.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"keyra": "./dist/index.js"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsup src/index.ts --format esm --target node18 --clean --shims",
|
|
10
|
+
"dev": "tsup src/index.ts --format esm --target node18 --watch --shims"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"env",
|
|
14
|
+
"dotenv",
|
|
15
|
+
"secrets",
|
|
16
|
+
"vault",
|
|
17
|
+
"cli",
|
|
18
|
+
"encryption"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"type": "module",
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"boxen": "^8.0.1",
|
|
24
|
+
"chalk": "^5.6.2",
|
|
25
|
+
"commander": "^14.0.3",
|
|
26
|
+
"inquirer": "^13.2.5",
|
|
27
|
+
"open": "^11.0.0",
|
|
28
|
+
"ora": "^9.3.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/inquirer": "^9.0.9",
|
|
32
|
+
"@types/node": "^25.2.3",
|
|
33
|
+
"tsup": "^8.5.1",
|
|
34
|
+
"typescript": "^5.9.3"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { success, error, warning, printBox } from '../lib/ui.js';
|
|
6
|
+
|
|
7
|
+
const HOOK_MARKER = '# keyra-guard';
|
|
8
|
+
const PRE_COMMIT_HOOK = `#!/bin/sh
|
|
9
|
+
${HOOK_MARKER}
|
|
10
|
+
# Keyra Git Guardian — blocks accidental .env commits
|
|
11
|
+
# https://keyra.dev
|
|
12
|
+
|
|
13
|
+
staged=$(git diff --cached --name-only 2>/dev/null)
|
|
14
|
+
|
|
15
|
+
for file in $staged; do
|
|
16
|
+
if echo "$file" | grep -qE '(^|\\.)\\.env(\\..*)?$'; then
|
|
17
|
+
echo ""
|
|
18
|
+
echo " \\033[0;31m✗ Keyra Git Guardian blocked your commit\\033[0m"
|
|
19
|
+
echo " \\033[0;33m⚠ Detected .env file in staged changes: $file\\033[0m"
|
|
20
|
+
echo ""
|
|
21
|
+
echo " Commit secrets to your Keyra vault instead:"
|
|
22
|
+
echo " keyra push"
|
|
23
|
+
echo ""
|
|
24
|
+
exit 1
|
|
25
|
+
fi
|
|
26
|
+
done
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
export const guardCommand = new Command('guard')
|
|
30
|
+
.description('Install a pre-commit hook to block accidental .env commits')
|
|
31
|
+
.action(async () => {
|
|
32
|
+
// Find .git directory
|
|
33
|
+
let gitDir: string | null = null;
|
|
34
|
+
let dir = process.cwd();
|
|
35
|
+
for (let i = 0; i < 10; i++) {
|
|
36
|
+
const candidate = path.join(dir, '.git');
|
|
37
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
|
|
38
|
+
gitDir = candidate;
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
const parent = path.dirname(dir);
|
|
42
|
+
if (parent === dir) break;
|
|
43
|
+
dir = parent;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!gitDir) {
|
|
47
|
+
error('Not inside a git repository.');
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const hooksDir = path.join(gitDir, 'hooks');
|
|
52
|
+
if (!fs.existsSync(hooksDir)) {
|
|
53
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const hookPath = path.join(hooksDir, 'pre-commit');
|
|
57
|
+
|
|
58
|
+
// Check if already installed
|
|
59
|
+
if (fs.existsSync(hookPath)) {
|
|
60
|
+
const existing = fs.readFileSync(hookPath, 'utf-8');
|
|
61
|
+
if (existing.includes(HOOK_MARKER)) {
|
|
62
|
+
warning('Git Guardian is already installed in this repository.');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// Append to existing hook
|
|
66
|
+
fs.appendFileSync(hookPath, '\n' + PRE_COMMIT_HOOK);
|
|
67
|
+
success('Git Guardian appended to existing pre-commit hook.');
|
|
68
|
+
} else {
|
|
69
|
+
fs.writeFileSync(hookPath, PRE_COMMIT_HOOK);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Make executable (no-op on Windows, but harmless)
|
|
73
|
+
try {
|
|
74
|
+
fs.chmodSync(hookPath, 0o755);
|
|
75
|
+
} catch {
|
|
76
|
+
// Windows doesn't support chmod
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
printBox(
|
|
80
|
+
chalk.green.bold('Git Guardian installed') +
|
|
81
|
+
'\n\n' +
|
|
82
|
+
chalk.dim('Pre-commit hook added to: ') +
|
|
83
|
+
chalk.white(hookPath) +
|
|
84
|
+
'\n\n' +
|
|
85
|
+
chalk.dim('Now if you accidentally stage a .env file,\n') +
|
|
86
|
+
chalk.dim('git commit will be blocked automatically.'),
|
|
87
|
+
'green'
|
|
88
|
+
);
|
|
89
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import inquirer from 'inquirer';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import {
|
|
6
|
+
requireAuth,
|
|
7
|
+
hasProjectConfig,
|
|
8
|
+
loadProjectConfig,
|
|
9
|
+
saveProjectConfig,
|
|
10
|
+
addToGitignore,
|
|
11
|
+
} from '../lib/config.js';
|
|
12
|
+
import { createProject, upsertVaultEntries } from '../lib/api.js';
|
|
13
|
+
import { readEnvFile } from '../lib/env-file.js';
|
|
14
|
+
import { encrypt } from '../lib/encryption.js';
|
|
15
|
+
import { success, error, info, spinner, printBox } from '../lib/ui.js';
|
|
16
|
+
|
|
17
|
+
export const initCommand = new Command('init')
|
|
18
|
+
.description('Link current directory to a Keyra project')
|
|
19
|
+
.action(async () => {
|
|
20
|
+
let config;
|
|
21
|
+
try {
|
|
22
|
+
config = requireAuth();
|
|
23
|
+
} catch (err: any) {
|
|
24
|
+
error(err.message);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (hasProjectConfig()) {
|
|
29
|
+
const existing = loadProjectConfig();
|
|
30
|
+
info(
|
|
31
|
+
`This project is already linked to vault project: ${chalk.bold(existing?.projectName)}`
|
|
32
|
+
);
|
|
33
|
+
info('Run `keyra push` or `keyra pull` to sync.');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const folderName = path.basename(process.cwd());
|
|
38
|
+
|
|
39
|
+
const { name } = await inquirer.prompt([
|
|
40
|
+
{
|
|
41
|
+
type: 'input',
|
|
42
|
+
name: 'name',
|
|
43
|
+
message: 'Project name:',
|
|
44
|
+
default: folderName,
|
|
45
|
+
validate: (input: string) =>
|
|
46
|
+
input.trim().length > 0 || 'Name is required',
|
|
47
|
+
},
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const s = spinner('Creating project...');
|
|
51
|
+
s.start();
|
|
52
|
+
|
|
53
|
+
let project;
|
|
54
|
+
try {
|
|
55
|
+
project = await createProject(name.trim());
|
|
56
|
+
s.stop();
|
|
57
|
+
success(`Project "${project.name}" created`);
|
|
58
|
+
} catch (err: any) {
|
|
59
|
+
s.stop();
|
|
60
|
+
error(err.message);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Check for existing .env file
|
|
65
|
+
const envData = readEnvFile();
|
|
66
|
+
if (envData) {
|
|
67
|
+
const varCount = Object.keys(envData.vars).length;
|
|
68
|
+
const { importVars } = await inquirer.prompt([
|
|
69
|
+
{
|
|
70
|
+
type: 'confirm',
|
|
71
|
+
name: 'importVars',
|
|
72
|
+
message: `Found ${envData.filename} with ${varCount} variable${varCount !== 1 ? 's' : ''}. Import them to your vault?`,
|
|
73
|
+
default: true,
|
|
74
|
+
},
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
if (importVars) {
|
|
78
|
+
const uploadSpinner = spinner('Encrypting and uploading...');
|
|
79
|
+
uploadSpinner.start();
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const entries = Object.entries(envData.vars).map(([key, value]) => {
|
|
83
|
+
const encrypted = encrypt(value, config.passphrase);
|
|
84
|
+
return {
|
|
85
|
+
key_name: key,
|
|
86
|
+
encrypted_value: encrypted.encrypted,
|
|
87
|
+
iv: encrypted.iv,
|
|
88
|
+
salt: encrypted.salt,
|
|
89
|
+
auth_tag: encrypted.authTag,
|
|
90
|
+
category: guessCategory(key),
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await upsertVaultEntries(project.id, entries);
|
|
95
|
+
uploadSpinner.stop();
|
|
96
|
+
success(`Imported ${entries.length} variable${entries.length !== 1 ? 's' : ''} to vault`);
|
|
97
|
+
} catch (err: any) {
|
|
98
|
+
uploadSpinner.stop();
|
|
99
|
+
error(`Failed to import: ${err.message}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
saveProjectConfig({
|
|
105
|
+
projectId: project.id,
|
|
106
|
+
projectName: project.name,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
addToGitignore('.keyra.json');
|
|
110
|
+
|
|
111
|
+
printBox(
|
|
112
|
+
chalk.green.bold(`Project "${project.name}" created and linked.`) +
|
|
113
|
+
'\n\n' +
|
|
114
|
+
chalk.dim('Use ') +
|
|
115
|
+
chalk.white('keyra push') +
|
|
116
|
+
chalk.dim(' and ') +
|
|
117
|
+
chalk.white('keyra pull') +
|
|
118
|
+
chalk.dim(' to sync.')
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
function guessCategory(key: string): string {
|
|
123
|
+
const k = key.toUpperCase();
|
|
124
|
+
if (k.includes('OPENAI') || k.includes('ANTHROPIC') || k.includes('AI'))
|
|
125
|
+
return 'ai';
|
|
126
|
+
if (
|
|
127
|
+
k.includes('DATABASE') ||
|
|
128
|
+
k.includes('DB_') ||
|
|
129
|
+
k.includes('POSTGRES') ||
|
|
130
|
+
k.includes('MYSQL') ||
|
|
131
|
+
k.includes('MONGO') ||
|
|
132
|
+
k.includes('REDIS') ||
|
|
133
|
+
k.includes('SUPABASE')
|
|
134
|
+
)
|
|
135
|
+
return 'database';
|
|
136
|
+
if (
|
|
137
|
+
k.includes('AUTH') ||
|
|
138
|
+
k.includes('JWT') ||
|
|
139
|
+
k.includes('SESSION') ||
|
|
140
|
+
k.includes('NEXTAUTH') ||
|
|
141
|
+
k.includes('CLERK')
|
|
142
|
+
)
|
|
143
|
+
return 'auth';
|
|
144
|
+
if (k.includes('STRIPE') || k.includes('PAYMENT') || k.includes('PAYPAL'))
|
|
145
|
+
return 'payment';
|
|
146
|
+
if (
|
|
147
|
+
k.includes('VERCEL') ||
|
|
148
|
+
k.includes('AWS') ||
|
|
149
|
+
k.includes('GCP') ||
|
|
150
|
+
k.includes('AZURE') ||
|
|
151
|
+
k.includes('HEROKU') ||
|
|
152
|
+
k.includes('DEPLOY')
|
|
153
|
+
)
|
|
154
|
+
return 'hosting';
|
|
155
|
+
if (
|
|
156
|
+
k.includes('SMTP') ||
|
|
157
|
+
k.includes('EMAIL') ||
|
|
158
|
+
k.includes('SENDGRID') ||
|
|
159
|
+
k.includes('RESEND') ||
|
|
160
|
+
k.includes('MAILGUN')
|
|
161
|
+
)
|
|
162
|
+
return 'email';
|
|
163
|
+
if (
|
|
164
|
+
k.includes('ANALYTICS') ||
|
|
165
|
+
k.includes('MIXPANEL') ||
|
|
166
|
+
k.includes('GA_') ||
|
|
167
|
+
k.includes('POSTHOG') ||
|
|
168
|
+
k.includes('SENTRY')
|
|
169
|
+
)
|
|
170
|
+
return 'analytics';
|
|
171
|
+
return 'general';
|
|
172
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { requireAuth } from '../lib/config.js';
|
|
4
|
+
import { getProjects } from '../lib/api.js';
|
|
5
|
+
import { error, info, spinner } from '../lib/ui.js';
|
|
6
|
+
|
|
7
|
+
export const listCommand = new Command('list')
|
|
8
|
+
.alias('ls')
|
|
9
|
+
.description('List all your projects')
|
|
10
|
+
.action(async () => {
|
|
11
|
+
try {
|
|
12
|
+
requireAuth();
|
|
13
|
+
} catch (err: any) {
|
|
14
|
+
error(err.message);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const s = spinner('Loading projects...');
|
|
19
|
+
s.start();
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const projects = await getProjects();
|
|
23
|
+
s.stop();
|
|
24
|
+
|
|
25
|
+
if (projects.length === 0) {
|
|
26
|
+
info('No projects yet. Run `keyra init` in a project folder.');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
console.log();
|
|
31
|
+
console.log(chalk.bold(` ${projects.length} project${projects.length !== 1 ? 's' : ''}:`));
|
|
32
|
+
console.log();
|
|
33
|
+
|
|
34
|
+
// Calculate column widths
|
|
35
|
+
const maxName = Math.max(...projects.map((p) => p.name.length), 4);
|
|
36
|
+
|
|
37
|
+
// Header
|
|
38
|
+
console.log(
|
|
39
|
+
chalk.dim(
|
|
40
|
+
` ${'Name'.padEnd(maxName + 2)}${'Vars'.padEnd(8)}Last Synced`
|
|
41
|
+
)
|
|
42
|
+
);
|
|
43
|
+
console.log(chalk.dim(` ${'─'.repeat(maxName + 2 + 8 + 20)}`));
|
|
44
|
+
|
|
45
|
+
for (const project of projects) {
|
|
46
|
+
const name = chalk.green(project.name.padEnd(maxName + 2));
|
|
47
|
+
const vars = String(project.var_count).padEnd(8);
|
|
48
|
+
const synced = project.last_synced_at
|
|
49
|
+
? new Date(project.last_synced_at).toLocaleDateString()
|
|
50
|
+
: chalk.dim('never');
|
|
51
|
+
|
|
52
|
+
console.log(` ${name}${vars}${synced}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.log();
|
|
56
|
+
} catch (err: any) {
|
|
57
|
+
s.stop();
|
|
58
|
+
error(err.message);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import inquirer from 'inquirer';
|
|
3
|
+
import open from 'open';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { createDeviceCode, pollAuth } from '../lib/api.js';
|
|
6
|
+
import { saveConfig } from '../lib/config.js';
|
|
7
|
+
import { banner, spinner, success, error, printBox } from '../lib/ui.js';
|
|
8
|
+
|
|
9
|
+
export const loginCommand = new Command('login')
|
|
10
|
+
.description('Authenticate with Keyra via browser')
|
|
11
|
+
.option('--api-url <url>', 'API URL', 'https://keyra.dev')
|
|
12
|
+
.action(async (options) => {
|
|
13
|
+
banner();
|
|
14
|
+
|
|
15
|
+
const baseUrl = options.apiUrl;
|
|
16
|
+
saveConfig({ apiUrl: baseUrl });
|
|
17
|
+
|
|
18
|
+
const s = spinner('Creating device code...');
|
|
19
|
+
s.start();
|
|
20
|
+
|
|
21
|
+
let deviceCode: string;
|
|
22
|
+
try {
|
|
23
|
+
const result = await createDeviceCode();
|
|
24
|
+
deviceCode = result.deviceCode;
|
|
25
|
+
s.stop();
|
|
26
|
+
} catch (err: any) {
|
|
27
|
+
s.stop();
|
|
28
|
+
error(err.message);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const authUrl = `${baseUrl}/cli/auth?code=${deviceCode}`;
|
|
32
|
+
|
|
33
|
+
console.log();
|
|
34
|
+
console.log(chalk.dim(' Opening browser to authenticate...'));
|
|
35
|
+
console.log(chalk.dim(` ${authUrl}`));
|
|
36
|
+
console.log();
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
await open(authUrl);
|
|
40
|
+
} catch {
|
|
41
|
+
console.log(chalk.yellow(' Could not open browser automatically.'));
|
|
42
|
+
console.log(chalk.yellow(` Open this URL manually: ${authUrl}`));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const pollSpinner = spinner('Waiting for you to sign in...');
|
|
46
|
+
pollSpinner.start();
|
|
47
|
+
|
|
48
|
+
const maxAttempts = 150; // 5 minutes at 2 second intervals
|
|
49
|
+
let accessToken: string | undefined;
|
|
50
|
+
|
|
51
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
52
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const result = await pollAuth(deviceCode);
|
|
56
|
+
if (result.status === 'authorized' && result.accessToken) {
|
|
57
|
+
accessToken = result.accessToken;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
if (result.status === 'expired') {
|
|
61
|
+
pollSpinner.stop();
|
|
62
|
+
error('Authentication timed out. Please try again.');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
// Polling error, continue
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
pollSpinner.stop();
|
|
71
|
+
|
|
72
|
+
if (!accessToken) {
|
|
73
|
+
error('Authentication timed out. Please try again.');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
success('Signed in successfully!');
|
|
78
|
+
console.log();
|
|
79
|
+
|
|
80
|
+
const { passphrase } = await inquirer.prompt([
|
|
81
|
+
{
|
|
82
|
+
type: 'password',
|
|
83
|
+
name: 'passphrase',
|
|
84
|
+
message:
|
|
85
|
+
'Enter an encryption passphrase (this encrypts your secrets locally — remember it!):',
|
|
86
|
+
mask: '*',
|
|
87
|
+
validate: (input: string) =>
|
|
88
|
+
input.length >= 4 || 'Passphrase must be at least 4 characters',
|
|
89
|
+
},
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
const { confirm } = await inquirer.prompt([
|
|
93
|
+
{
|
|
94
|
+
type: 'password',
|
|
95
|
+
name: 'confirm',
|
|
96
|
+
message: 'Confirm passphrase:',
|
|
97
|
+
mask: '*',
|
|
98
|
+
validate: (input: string) =>
|
|
99
|
+
input === passphrase || 'Passphrases do not match',
|
|
100
|
+
},
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
saveConfig({
|
|
104
|
+
apiUrl: baseUrl,
|
|
105
|
+
accessToken,
|
|
106
|
+
passphrase,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
printBox(
|
|
110
|
+
chalk.green.bold('You\'re logged in!') +
|
|
111
|
+
'\n\n' +
|
|
112
|
+
chalk.dim('Run ') +
|
|
113
|
+
chalk.white('keyra init') +
|
|
114
|
+
chalk.dim(' in a project to get started.')
|
|
115
|
+
);
|
|
116
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { clearConfig } from '../lib/config.js';
|
|
3
|
+
import { success } from '../lib/ui.js';
|
|
4
|
+
|
|
5
|
+
export const logoutCommand = new Command('logout')
|
|
6
|
+
.description('Clear stored credentials')
|
|
7
|
+
.action(() => {
|
|
8
|
+
clearConfig();
|
|
9
|
+
success('Logged out. Your vault data is still safe in the cloud.');
|
|
10
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { requireAuth, requireProjectConfig } from '../lib/config.js';
|
|
6
|
+
import { getVaultEntries, getProjects } from '../lib/api.js';
|
|
7
|
+
import { decrypt } from '../lib/encryption.js';
|
|
8
|
+
import { writeEnvFile } from '../lib/env-file.js';
|
|
9
|
+
import { success, error, warning, spinner, promptSecret } from '../lib/ui.js';
|
|
10
|
+
|
|
11
|
+
export const pullCommand = new Command('pull')
|
|
12
|
+
.description('Pull vault variables to local .env')
|
|
13
|
+
.option('-f, --file <filename>', 'Output filename', '.env')
|
|
14
|
+
.action(async (options) => {
|
|
15
|
+
let config;
|
|
16
|
+
try {
|
|
17
|
+
config = requireAuth();
|
|
18
|
+
} catch (err: any) {
|
|
19
|
+
error(err.message);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let projectConfig;
|
|
24
|
+
try {
|
|
25
|
+
projectConfig = requireProjectConfig();
|
|
26
|
+
} catch (err: any) {
|
|
27
|
+
error(err.message);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Check if project has a password
|
|
32
|
+
let projectPassword: string | null = null;
|
|
33
|
+
try {
|
|
34
|
+
const projects = await getProjects();
|
|
35
|
+
const project = projects.find((p) => p.id === projectConfig.projectId);
|
|
36
|
+
if (project?.has_project_password) {
|
|
37
|
+
projectPassword = await promptSecret('Project password: ');
|
|
38
|
+
const blob = JSON.parse(project.project_password_salt!) as { encrypted: string; iv: string; salt: string; authTag: string };
|
|
39
|
+
try {
|
|
40
|
+
const result = decrypt(blob.encrypted, blob.iv, blob.salt, blob.authTag, projectPassword);
|
|
41
|
+
if (result !== 'keyra-verified') {
|
|
42
|
+
error('Incorrect project password');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
error('Incorrect project password');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch (err: any) {
|
|
51
|
+
error('Failed to fetch project info: ' + err.message);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const s = spinner('Pulling from vault...');
|
|
56
|
+
s.start();
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const entries = await getVaultEntries(projectConfig.projectId);
|
|
60
|
+
|
|
61
|
+
if (entries.length === 0) {
|
|
62
|
+
s.stop();
|
|
63
|
+
warning('No variables found in vault. Push some first with `keyra push`.');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const vars: Record<string, string> = {};
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
if (projectPassword) {
|
|
70
|
+
// Double decrypt: outer with vault passphrase, inner with project password
|
|
71
|
+
const innerJson = decrypt(entry.encrypted_value, entry.iv, entry.salt, entry.auth_tag, config.passphrase);
|
|
72
|
+
const inner = JSON.parse(innerJson) as { encrypted: string; iv: string; salt: string; authTag: string };
|
|
73
|
+
vars[entry.key_name] = decrypt(inner.encrypted, inner.iv, inner.salt, inner.authTag, projectPassword);
|
|
74
|
+
} else {
|
|
75
|
+
vars[entry.key_name] = decrypt(entry.encrypted_value, entry.iv, entry.salt, entry.auth_tag, config.passphrase);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const existed = fs.existsSync(path.join(process.cwd(), options.file));
|
|
80
|
+
writeEnvFile(vars, undefined, options.file);
|
|
81
|
+
|
|
82
|
+
s.stop();
|
|
83
|
+
success(
|
|
84
|
+
'Pulled ' + entries.length + ' variable' + (entries.length !== 1 ? 's' : '') + ' from vault -> ' + options.file + ' ' + chalk.dim('(' + projectConfig.projectName + ')')
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
if (existed) {
|
|
88
|
+
warning('Existing ' + options.file + ' was overwritten');
|
|
89
|
+
}
|
|
90
|
+
} catch (err: any) {
|
|
91
|
+
s.stop();
|
|
92
|
+
error(err.message);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { requireAuth, requireProjectConfig } from '../lib/config.js';
|
|
4
|
+
import { upsertVaultEntries, getProjects } from '../lib/api.js';
|
|
5
|
+
import { readEnvFile } from '../lib/env-file.js';
|
|
6
|
+
import { encrypt, decrypt } from '../lib/encryption.js';
|
|
7
|
+
import { success, error, warning, spinner, promptSecret } from '../lib/ui.js';
|
|
8
|
+
|
|
9
|
+
export const pushCommand = new Command('push')
|
|
10
|
+
.description('Push local .env to vault')
|
|
11
|
+
.action(async () => {
|
|
12
|
+
let config;
|
|
13
|
+
try {
|
|
14
|
+
config = requireAuth();
|
|
15
|
+
} catch (err: any) {
|
|
16
|
+
error(err.message);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let projectConfig;
|
|
21
|
+
try {
|
|
22
|
+
projectConfig = requireProjectConfig();
|
|
23
|
+
} catch (err: any) {
|
|
24
|
+
error(err.message);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const envData = readEnvFile();
|
|
29
|
+
if (!envData) {
|
|
30
|
+
warning('No .env file found in current directory.');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const vars = envData.vars;
|
|
35
|
+
const varCount = Object.keys(vars).length;
|
|
36
|
+
|
|
37
|
+
if (varCount === 0) {
|
|
38
|
+
warning('No variables found in .env file.');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Check if project has a password
|
|
43
|
+
let projectPassword: string | null = null;
|
|
44
|
+
try {
|
|
45
|
+
const projects = await getProjects();
|
|
46
|
+
const project = projects.find((p) => p.id === projectConfig.projectId);
|
|
47
|
+
if (project?.has_project_password) {
|
|
48
|
+
projectPassword = await promptSecret('Project password: ');
|
|
49
|
+
const blob = JSON.parse(project.project_password_salt as string) as { encrypted: string; iv: string; salt: string; authTag: string };
|
|
50
|
+
try {
|
|
51
|
+
const result = decrypt(blob.encrypted, blob.iv, blob.salt, blob.authTag, projectPassword);
|
|
52
|
+
if (result !== 'keyra-verified') {
|
|
53
|
+
error('Incorrect project password');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
error('Incorrect project password');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} catch (err: any) {
|
|
62
|
+
error('Failed to fetch project info: ' + err.message);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const s = spinner('Encrypting and uploading ' + varCount + ' variable' + (varCount !== 1 ? 's' : '') + '...');
|
|
67
|
+
s.start();
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const entries = Object.entries(vars).map(([key, value]) => {
|
|
71
|
+
let encrypted;
|
|
72
|
+
if (projectPassword) {
|
|
73
|
+
// Double encrypt: inner with project password, outer with vault passphrase
|
|
74
|
+
const inner = encrypt(value, projectPassword);
|
|
75
|
+
const innerJson = JSON.stringify(inner);
|
|
76
|
+
encrypted = encrypt(innerJson, config.passphrase);
|
|
77
|
+
} else {
|
|
78
|
+
encrypted = encrypt(value, config.passphrase);
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
key_name: key,
|
|
82
|
+
encrypted_value: encrypted.encrypted,
|
|
83
|
+
iv: encrypted.iv,
|
|
84
|
+
salt: encrypted.salt,
|
|
85
|
+
auth_tag: encrypted.authTag,
|
|
86
|
+
category: guessCategory(key),
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await upsertVaultEntries(projectConfig.projectId, entries);
|
|
91
|
+
s.stop();
|
|
92
|
+
success(
|
|
93
|
+
'Pushed ' + varCount + ' variable' + (varCount !== 1 ? 's' : '') + ' to vault ' + chalk.dim('(' + projectConfig.projectName + ')')
|
|
94
|
+
);
|
|
95
|
+
} catch (err: any) {
|
|
96
|
+
s.stop();
|
|
97
|
+
error(err.message);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
function guessCategory(key: string): string {
|
|
102
|
+
const k = key.toUpperCase();
|
|
103
|
+
if (k.includes('OPENAI') || k.includes('ANTHROPIC') || k.includes('AI'))
|
|
104
|
+
return 'ai';
|
|
105
|
+
if (k.includes('DATABASE') || k.includes('DB_') || k.includes('POSTGRES') || k.includes('MYSQL') || k.includes('MONGO') || k.includes('REDIS') || k.includes('SUPABASE'))
|
|
106
|
+
return 'database';
|
|
107
|
+
if (k.includes('AUTH') || k.includes('JWT') || k.includes('SESSION') || k.includes('NEXTAUTH') || k.includes('CLERK'))
|
|
108
|
+
return 'auth';
|
|
109
|
+
if (k.includes('STRIPE') || k.includes('PAYMENT') || k.includes('PAYPAL'))
|
|
110
|
+
return 'payment';
|
|
111
|
+
if (k.includes('VERCEL') || k.includes('AWS') || k.includes('GCP') || k.includes('AZURE') || k.includes('HEROKU'))
|
|
112
|
+
return 'hosting';
|
|
113
|
+
if (k.includes('SMTP') || k.includes('EMAIL') || k.includes('SENDGRID') || k.includes('RESEND') || k.includes('MAILGUN'))
|
|
114
|
+
return 'email';
|
|
115
|
+
if (k.includes('ANALYTICS') || k.includes('MIXPANEL') || k.includes('GA_') || k.includes('POSTHOG') || k.includes('SENTRY'))
|
|
116
|
+
return 'analytics';
|
|
117
|
+
return 'general';
|
|
118
|
+
}
|