keyra-cli 0.1.0 → 0.1.2
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/cli/dist/index.js +1147 -0
- package/cli/package-lock.json +7 -3
- package/cli/package.json +8 -4
- package/package.json +1 -1
- package/cli/src/commands/guard.ts +0 -89
- package/cli/src/commands/init.ts +0 -172
- package/cli/src/commands/list.ts +0 -60
- package/cli/src/commands/login.ts +0 -116
- package/cli/src/commands/logout.ts +0 -10
- package/cli/src/commands/pull.ts +0 -94
- package/cli/src/commands/push.ts +0 -118
- package/cli/src/commands/scan.ts +0 -145
- package/cli/src/commands/share.ts +0 -84
- package/cli/src/commands/status.ts +0 -91
- package/cli/src/commands/validate.ts +0 -101
- package/cli/src/index.ts +0 -38
- package/cli/src/lib/api.ts +0 -136
- package/cli/src/lib/config.ts +0 -94
- package/cli/src/lib/encryption.ts +0 -45
- package/cli/src/lib/env-file.ts +0 -67
- package/cli/src/lib/ui.ts +0 -87
- package/cli/src/types.ts +0 -56
- package/cli/tsconfig.json +0 -14
|
@@ -0,0 +1,1147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command as Command12 } from "commander";
|
|
5
|
+
import chalk12 from "chalk";
|
|
6
|
+
|
|
7
|
+
// src/commands/login.ts
|
|
8
|
+
import { Command } from "commander";
|
|
9
|
+
import inquirer from "inquirer";
|
|
10
|
+
import open from "open";
|
|
11
|
+
import chalk2 from "chalk";
|
|
12
|
+
|
|
13
|
+
// src/lib/config.ts
|
|
14
|
+
import fs from "fs";
|
|
15
|
+
import path from "path";
|
|
16
|
+
import os from "os";
|
|
17
|
+
var CONFIG_DIR = path.join(os.homedir(), ".keyra");
|
|
18
|
+
var CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
19
|
+
var PROJECT_CONFIG_FILE = ".keyra.json";
|
|
20
|
+
function ensureConfigDir() {
|
|
21
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
22
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function loadConfig() {
|
|
26
|
+
ensureConfigDir();
|
|
27
|
+
if (!fs.existsSync(CONFIG_FILE)) return {};
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"));
|
|
30
|
+
} catch {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function saveConfig(partial) {
|
|
35
|
+
ensureConfigDir();
|
|
36
|
+
const existing = loadConfig();
|
|
37
|
+
const merged = { ...existing, ...partial };
|
|
38
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2));
|
|
39
|
+
}
|
|
40
|
+
function clearConfig() {
|
|
41
|
+
ensureConfigDir();
|
|
42
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
43
|
+
fs.unlinkSync(CONFIG_FILE);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function isLoggedIn() {
|
|
47
|
+
const config = loadConfig();
|
|
48
|
+
return !!(config.accessToken && config.passphrase);
|
|
49
|
+
}
|
|
50
|
+
function requireAuth() {
|
|
51
|
+
const config = loadConfig();
|
|
52
|
+
if (!config.accessToken || !config.passphrase) {
|
|
53
|
+
throw new Error("Not logged in. Run `keyra-cli login` first.");
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
apiUrl: config.apiUrl || "http://localhost:3000",
|
|
57
|
+
accessToken: config.accessToken,
|
|
58
|
+
passphrase: config.passphrase
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function loadProjectConfig() {
|
|
62
|
+
const filePath = path.join(process.cwd(), PROJECT_CONFIG_FILE);
|
|
63
|
+
if (!fs.existsSync(filePath)) return null;
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function saveProjectConfig(data) {
|
|
71
|
+
const filePath = path.join(process.cwd(), PROJECT_CONFIG_FILE);
|
|
72
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
73
|
+
}
|
|
74
|
+
function hasProjectConfig() {
|
|
75
|
+
return fs.existsSync(path.join(process.cwd(), PROJECT_CONFIG_FILE));
|
|
76
|
+
}
|
|
77
|
+
function requireProjectConfig() {
|
|
78
|
+
const config = loadProjectConfig();
|
|
79
|
+
if (!config) {
|
|
80
|
+
throw new Error("Not in a Keyra project. Run `keyra-cli init` first.");
|
|
81
|
+
}
|
|
82
|
+
return config;
|
|
83
|
+
}
|
|
84
|
+
function addToGitignore(entry) {
|
|
85
|
+
const gitignorePath = path.join(process.cwd(), ".gitignore");
|
|
86
|
+
if (fs.existsSync(gitignorePath)) {
|
|
87
|
+
const content = fs.readFileSync(gitignorePath, "utf-8");
|
|
88
|
+
if (!content.includes(entry)) {
|
|
89
|
+
fs.appendFileSync(gitignorePath, `
|
|
90
|
+
${entry}
|
|
91
|
+
`);
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
fs.writeFileSync(gitignorePath, `${entry}
|
|
95
|
+
`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// src/lib/api.ts
|
|
100
|
+
function getBaseUrl() {
|
|
101
|
+
const config = loadConfig();
|
|
102
|
+
return config.apiUrl || "http://localhost:3000";
|
|
103
|
+
}
|
|
104
|
+
function getHeaders() {
|
|
105
|
+
const config = loadConfig();
|
|
106
|
+
const headers = {
|
|
107
|
+
"Content-Type": "application/json"
|
|
108
|
+
};
|
|
109
|
+
if (config.accessToken) {
|
|
110
|
+
headers["Authorization"] = `Bearer ${config.accessToken}`;
|
|
111
|
+
}
|
|
112
|
+
return headers;
|
|
113
|
+
}
|
|
114
|
+
async function handleResponse(res) {
|
|
115
|
+
if (res.status === 401) {
|
|
116
|
+
throw new Error("Session expired. Run `keyra-cli login` to re-authenticate.");
|
|
117
|
+
}
|
|
118
|
+
if (res.status === 403) {
|
|
119
|
+
const data = await res.json().catch(() => ({}));
|
|
120
|
+
const msg = data.error || "";
|
|
121
|
+
if (msg.toLowerCase().includes("project")) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
"Project limit reached for your plan.\n Upgrade at keyra.dev/dashboard/settings for more projects."
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
if (msg.toLowerCase().includes("variable") || msg.toLowerCase().includes("vars")) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
"Variable limit reached for this project.\n Upgrade at keyra.dev/dashboard/settings for more variables."
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
if (msg.toLowerCase().includes("sharing") || msg.toLowerCase().includes("share")) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
"Sharing requires a Pro plan.\n Upgrade at keyra.dev/dashboard/settings to enable sharing."
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (data.upgrade) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`${msg || "Plan limit reached."}
|
|
139
|
+
Upgrade at keyra.dev/dashboard/settings`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
throw new Error(msg || "Access denied.");
|
|
143
|
+
}
|
|
144
|
+
if (!res.ok) {
|
|
145
|
+
const data = await res.json().catch(() => ({}));
|
|
146
|
+
throw new Error(data.error || `Request failed with status ${res.status}`);
|
|
147
|
+
}
|
|
148
|
+
return res.json();
|
|
149
|
+
}
|
|
150
|
+
async function apiGet(path7) {
|
|
151
|
+
const res = await fetch(`${getBaseUrl()}${path7}`, {
|
|
152
|
+
method: "GET",
|
|
153
|
+
headers: getHeaders()
|
|
154
|
+
}).catch(() => {
|
|
155
|
+
throw new Error("Can't reach server. Check your connection.");
|
|
156
|
+
});
|
|
157
|
+
return handleResponse(res);
|
|
158
|
+
}
|
|
159
|
+
async function apiPost(path7, body) {
|
|
160
|
+
const res = await fetch(`${getBaseUrl()}${path7}`, {
|
|
161
|
+
method: "POST",
|
|
162
|
+
headers: getHeaders(),
|
|
163
|
+
body: JSON.stringify(body)
|
|
164
|
+
}).catch(() => {
|
|
165
|
+
throw new Error("Can't reach server. Check your connection.");
|
|
166
|
+
});
|
|
167
|
+
return handleResponse(res);
|
|
168
|
+
}
|
|
169
|
+
async function createDeviceCode() {
|
|
170
|
+
return apiPost("/api/cli/auth", {});
|
|
171
|
+
}
|
|
172
|
+
async function pollAuth(deviceCode) {
|
|
173
|
+
return apiGet(`/api/cli/auth?code=${deviceCode}`);
|
|
174
|
+
}
|
|
175
|
+
async function getProjects() {
|
|
176
|
+
return apiGet("/api/projects");
|
|
177
|
+
}
|
|
178
|
+
async function createProject(name, description) {
|
|
179
|
+
return apiPost("/api/projects", { name, description });
|
|
180
|
+
}
|
|
181
|
+
async function getVaultEntries(projectId) {
|
|
182
|
+
return apiGet(`/api/vault?project_id=${projectId}`);
|
|
183
|
+
}
|
|
184
|
+
async function upsertVaultEntries(projectId, entries) {
|
|
185
|
+
await apiPost("/api/vault/sync", { projectId, entries });
|
|
186
|
+
}
|
|
187
|
+
async function createShareLink(data) {
|
|
188
|
+
return apiPost("/api/share", data);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// src/lib/ui.ts
|
|
192
|
+
import readline from "readline";
|
|
193
|
+
import chalk from "chalk";
|
|
194
|
+
import ora from "ora";
|
|
195
|
+
import boxen from "boxen";
|
|
196
|
+
var spinner = (text) => ora({ text, color: "green" });
|
|
197
|
+
var success = (text) => console.log(chalk.green("\u2713"), text);
|
|
198
|
+
var error = (text) => console.log(chalk.red("\u2717"), text);
|
|
199
|
+
var warning = (text) => console.log(chalk.yellow("\u26A0"), text);
|
|
200
|
+
var info = (text) => console.log(chalk.blue("\u2139"), text);
|
|
201
|
+
function banner() {
|
|
202
|
+
console.log(
|
|
203
|
+
boxen(
|
|
204
|
+
chalk.green.bold("Keyra") + chalk.dim(" v0.1.0") + "\n" + chalk.dim("Encrypted .env vault"),
|
|
205
|
+
{
|
|
206
|
+
padding: 1,
|
|
207
|
+
margin: 1,
|
|
208
|
+
borderStyle: "round",
|
|
209
|
+
borderColor: "green"
|
|
210
|
+
}
|
|
211
|
+
)
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
function printTable(rows) {
|
|
215
|
+
const maxKeyLen = Math.max(...rows.map((r) => r.key.length), 3);
|
|
216
|
+
for (const row of rows) {
|
|
217
|
+
const dots = ".".repeat(Math.max(1, maxKeyLen - row.key.length + 3));
|
|
218
|
+
const statusIcon = row.status === "present" ? chalk.green("\u2713") : row.status === "missing" ? chalk.red("\u2717") : row.status === "empty" ? chalk.red("\u2717") : chalk.dim("\xB7");
|
|
219
|
+
const statusText = row.status === "present" ? chalk.green(row.value) : row.status === "missing" ? chalk.red(row.value) : row.status === "empty" ? chalk.red(row.value) : chalk.dim(row.value);
|
|
220
|
+
console.log(` ${statusIcon} ${chalk.bold(row.key)} ${chalk.dim(dots)} ${statusText}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function printBox(content, color = "green") {
|
|
224
|
+
console.log(
|
|
225
|
+
boxen(content, {
|
|
226
|
+
padding: 1,
|
|
227
|
+
margin: { top: 1, bottom: 1, left: 0, right: 0 },
|
|
228
|
+
borderStyle: "round",
|
|
229
|
+
borderColor: color
|
|
230
|
+
})
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
function promptSecret(question) {
|
|
234
|
+
return new Promise((resolve) => {
|
|
235
|
+
const rl = readline.createInterface({
|
|
236
|
+
input: process.stdin,
|
|
237
|
+
output: process.stdout
|
|
238
|
+
});
|
|
239
|
+
rl.question(question, (answer) => {
|
|
240
|
+
rl.close();
|
|
241
|
+
resolve(answer);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// src/commands/login.ts
|
|
247
|
+
var loginCommand = new Command("login").description("Authenticate with Keyra via browser").option("--api-url <url>", "API URL", "https://keyra.dev").action(async (options) => {
|
|
248
|
+
banner();
|
|
249
|
+
const baseUrl = options.apiUrl;
|
|
250
|
+
saveConfig({ apiUrl: baseUrl });
|
|
251
|
+
const s = spinner("Creating device code...");
|
|
252
|
+
s.start();
|
|
253
|
+
let deviceCode;
|
|
254
|
+
try {
|
|
255
|
+
const result = await createDeviceCode();
|
|
256
|
+
deviceCode = result.deviceCode;
|
|
257
|
+
s.stop();
|
|
258
|
+
} catch (err) {
|
|
259
|
+
s.stop();
|
|
260
|
+
error(err.message);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const authUrl = `${baseUrl}/cli/auth?code=${deviceCode}`;
|
|
264
|
+
console.log();
|
|
265
|
+
console.log(chalk2.dim(" Opening browser to authenticate..."));
|
|
266
|
+
console.log(chalk2.dim(` ${authUrl}`));
|
|
267
|
+
console.log();
|
|
268
|
+
try {
|
|
269
|
+
await open(authUrl);
|
|
270
|
+
} catch {
|
|
271
|
+
console.log(chalk2.yellow(" Could not open browser automatically."));
|
|
272
|
+
console.log(chalk2.yellow(` Open this URL manually: ${authUrl}`));
|
|
273
|
+
}
|
|
274
|
+
const pollSpinner = spinner("Waiting for you to sign in...");
|
|
275
|
+
pollSpinner.start();
|
|
276
|
+
const maxAttempts = 150;
|
|
277
|
+
let accessToken;
|
|
278
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
279
|
+
await new Promise((resolve) => setTimeout(resolve, 2e3));
|
|
280
|
+
try {
|
|
281
|
+
const result = await pollAuth(deviceCode);
|
|
282
|
+
if (result.status === "authorized" && result.accessToken) {
|
|
283
|
+
accessToken = result.accessToken;
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
if (result.status === "expired") {
|
|
287
|
+
pollSpinner.stop();
|
|
288
|
+
error("Authentication timed out. Please try again.");
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
} catch {
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
pollSpinner.stop();
|
|
295
|
+
if (!accessToken) {
|
|
296
|
+
error("Authentication timed out. Please try again.");
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
success("Signed in successfully!");
|
|
300
|
+
console.log();
|
|
301
|
+
const { passphrase } = await inquirer.prompt([
|
|
302
|
+
{
|
|
303
|
+
type: "password",
|
|
304
|
+
name: "passphrase",
|
|
305
|
+
message: "Enter an encryption passphrase (this encrypts your secrets locally \u2014 remember it!):",
|
|
306
|
+
mask: "*",
|
|
307
|
+
validate: (input) => input.length >= 4 || "Passphrase must be at least 4 characters"
|
|
308
|
+
}
|
|
309
|
+
]);
|
|
310
|
+
const { confirm } = await inquirer.prompt([
|
|
311
|
+
{
|
|
312
|
+
type: "password",
|
|
313
|
+
name: "confirm",
|
|
314
|
+
message: "Confirm passphrase:",
|
|
315
|
+
mask: "*",
|
|
316
|
+
validate: (input) => input === passphrase || "Passphrases do not match"
|
|
317
|
+
}
|
|
318
|
+
]);
|
|
319
|
+
saveConfig({
|
|
320
|
+
apiUrl: baseUrl,
|
|
321
|
+
accessToken,
|
|
322
|
+
passphrase
|
|
323
|
+
});
|
|
324
|
+
printBox(
|
|
325
|
+
chalk2.green.bold("You're logged in!") + "\n\n" + chalk2.dim("Run ") + chalk2.white("keyra-cli init") + chalk2.dim(" in a project to get started.")
|
|
326
|
+
);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// src/commands/logout.ts
|
|
330
|
+
import { Command as Command2 } from "commander";
|
|
331
|
+
var logoutCommand = new Command2("logout").description("Clear stored credentials").action(() => {
|
|
332
|
+
clearConfig();
|
|
333
|
+
success("Logged out. Your vault data is still safe in the cloud.");
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// src/commands/init.ts
|
|
337
|
+
import { Command as Command3 } from "commander";
|
|
338
|
+
import path3 from "path";
|
|
339
|
+
import inquirer2 from "inquirer";
|
|
340
|
+
import chalk3 from "chalk";
|
|
341
|
+
|
|
342
|
+
// src/lib/env-file.ts
|
|
343
|
+
import fs2 from "fs";
|
|
344
|
+
import path2 from "path";
|
|
345
|
+
function parseEnvFile(content) {
|
|
346
|
+
const vars = {};
|
|
347
|
+
const lines = content.split("\n");
|
|
348
|
+
for (const line of lines) {
|
|
349
|
+
const trimmed = line.trim();
|
|
350
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
351
|
+
const eqIndex = trimmed.indexOf("=");
|
|
352
|
+
if (eqIndex === -1) continue;
|
|
353
|
+
const key = trimmed.substring(0, eqIndex).trim();
|
|
354
|
+
let value = trimmed.substring(eqIndex + 1).trim();
|
|
355
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
356
|
+
value = value.slice(1, -1);
|
|
357
|
+
}
|
|
358
|
+
if (key) {
|
|
359
|
+
vars[key] = value;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return vars;
|
|
363
|
+
}
|
|
364
|
+
function toEnvFileString(vars) {
|
|
365
|
+
const keys = Object.keys(vars).sort();
|
|
366
|
+
return keys.map((key) => `${key}=${vars[key]}`).join("\n") + "\n";
|
|
367
|
+
}
|
|
368
|
+
var ENV_FILES = [".env", ".env.local", ".env.development"];
|
|
369
|
+
function readEnvFile(dir) {
|
|
370
|
+
const baseDir = dir || process.cwd();
|
|
371
|
+
for (const filename of ENV_FILES) {
|
|
372
|
+
const filePath = path2.join(baseDir, filename);
|
|
373
|
+
if (fs2.existsSync(filePath)) {
|
|
374
|
+
const content = fs2.readFileSync(filePath, "utf-8");
|
|
375
|
+
return { vars: parseEnvFile(content), filename };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
function writeEnvFile(vars, dir, filename = ".env") {
|
|
381
|
+
const baseDir = dir || process.cwd();
|
|
382
|
+
const filePath = path2.join(baseDir, filename);
|
|
383
|
+
fs2.writeFileSync(filePath, toEnvFileString(vars));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// src/lib/encryption.ts
|
|
387
|
+
import crypto from "crypto";
|
|
388
|
+
function encrypt(text, passphrase) {
|
|
389
|
+
const salt = crypto.randomBytes(16);
|
|
390
|
+
const key = crypto.pbkdf2Sync(passphrase, salt, 1e5, 32, "sha256");
|
|
391
|
+
const iv = crypto.randomBytes(12);
|
|
392
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
|
393
|
+
let encrypted = cipher.update(text, "utf8", "hex");
|
|
394
|
+
encrypted += cipher.final("hex");
|
|
395
|
+
const authTag = cipher.getAuthTag().toString("hex");
|
|
396
|
+
return {
|
|
397
|
+
encrypted,
|
|
398
|
+
iv: iv.toString("hex"),
|
|
399
|
+
salt: salt.toString("hex"),
|
|
400
|
+
authTag
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
function decrypt(encrypted, iv, salt, authTag, passphrase) {
|
|
404
|
+
const key = crypto.pbkdf2Sync(
|
|
405
|
+
passphrase,
|
|
406
|
+
Buffer.from(salt, "hex"),
|
|
407
|
+
1e5,
|
|
408
|
+
32,
|
|
409
|
+
"sha256"
|
|
410
|
+
);
|
|
411
|
+
const decipher = crypto.createDecipheriv(
|
|
412
|
+
"aes-256-gcm",
|
|
413
|
+
key,
|
|
414
|
+
Buffer.from(iv, "hex")
|
|
415
|
+
);
|
|
416
|
+
decipher.setAuthTag(Buffer.from(authTag, "hex"));
|
|
417
|
+
let decrypted = decipher.update(encrypted, "hex", "utf8");
|
|
418
|
+
decrypted += decipher.final("utf8");
|
|
419
|
+
return decrypted;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// src/commands/init.ts
|
|
423
|
+
var initCommand = new Command3("init").description("Link current directory to a Keyra project").action(async () => {
|
|
424
|
+
let config;
|
|
425
|
+
try {
|
|
426
|
+
config = requireAuth();
|
|
427
|
+
} catch (err) {
|
|
428
|
+
error(err.message);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (hasProjectConfig()) {
|
|
432
|
+
const existing = loadProjectConfig();
|
|
433
|
+
info(
|
|
434
|
+
`This project is already linked to vault project: ${chalk3.bold(existing?.projectName)}`
|
|
435
|
+
);
|
|
436
|
+
info("Run `keyra-cli push` or `keyra-cli pull` to sync.");
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const folderName = path3.basename(process.cwd());
|
|
440
|
+
const { name } = await inquirer2.prompt([
|
|
441
|
+
{
|
|
442
|
+
type: "input",
|
|
443
|
+
name: "name",
|
|
444
|
+
message: "Project name:",
|
|
445
|
+
default: folderName,
|
|
446
|
+
validate: (input) => input.trim().length > 0 || "Name is required"
|
|
447
|
+
}
|
|
448
|
+
]);
|
|
449
|
+
const s = spinner("Creating project...");
|
|
450
|
+
s.start();
|
|
451
|
+
let project;
|
|
452
|
+
try {
|
|
453
|
+
project = await createProject(name.trim());
|
|
454
|
+
s.stop();
|
|
455
|
+
success(`Project "${project.name}" created`);
|
|
456
|
+
} catch (err) {
|
|
457
|
+
s.stop();
|
|
458
|
+
error(err.message);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
const envData = readEnvFile();
|
|
462
|
+
if (envData) {
|
|
463
|
+
const varCount = Object.keys(envData.vars).length;
|
|
464
|
+
const { importVars } = await inquirer2.prompt([
|
|
465
|
+
{
|
|
466
|
+
type: "confirm",
|
|
467
|
+
name: "importVars",
|
|
468
|
+
message: `Found ${envData.filename} with ${varCount} variable${varCount !== 1 ? "s" : ""}. Import them to your vault?`,
|
|
469
|
+
default: true
|
|
470
|
+
}
|
|
471
|
+
]);
|
|
472
|
+
if (importVars) {
|
|
473
|
+
const uploadSpinner = spinner("Encrypting and uploading...");
|
|
474
|
+
uploadSpinner.start();
|
|
475
|
+
try {
|
|
476
|
+
const entries = Object.entries(envData.vars).map(([key, value]) => {
|
|
477
|
+
const encrypted = encrypt(value, config.passphrase);
|
|
478
|
+
return {
|
|
479
|
+
key_name: key,
|
|
480
|
+
encrypted_value: encrypted.encrypted,
|
|
481
|
+
iv: encrypted.iv,
|
|
482
|
+
salt: encrypted.salt,
|
|
483
|
+
auth_tag: encrypted.authTag,
|
|
484
|
+
category: guessCategory(key)
|
|
485
|
+
};
|
|
486
|
+
});
|
|
487
|
+
await upsertVaultEntries(project.id, entries);
|
|
488
|
+
uploadSpinner.stop();
|
|
489
|
+
success(`Imported ${entries.length} variable${entries.length !== 1 ? "s" : ""} to vault`);
|
|
490
|
+
} catch (err) {
|
|
491
|
+
uploadSpinner.stop();
|
|
492
|
+
error(`Failed to import: ${err.message}`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
saveProjectConfig({
|
|
497
|
+
projectId: project.id,
|
|
498
|
+
projectName: project.name
|
|
499
|
+
});
|
|
500
|
+
addToGitignore(".keyra.json");
|
|
501
|
+
printBox(
|
|
502
|
+
chalk3.green.bold(`Project "${project.name}" created and linked.`) + "\n\n" + chalk3.dim("Use ") + chalk3.white("keyra-cli push") + chalk3.dim(" and ") + chalk3.white("keyra-cli pull") + chalk3.dim(" to sync.")
|
|
503
|
+
);
|
|
504
|
+
});
|
|
505
|
+
function guessCategory(key) {
|
|
506
|
+
const k = key.toUpperCase();
|
|
507
|
+
if (k.includes("OPENAI") || k.includes("ANTHROPIC") || k.includes("AI"))
|
|
508
|
+
return "ai";
|
|
509
|
+
if (k.includes("DATABASE") || k.includes("DB_") || k.includes("POSTGRES") || k.includes("MYSQL") || k.includes("MONGO") || k.includes("REDIS") || k.includes("SUPABASE"))
|
|
510
|
+
return "database";
|
|
511
|
+
if (k.includes("AUTH") || k.includes("JWT") || k.includes("SESSION") || k.includes("NEXTAUTH") || k.includes("CLERK"))
|
|
512
|
+
return "auth";
|
|
513
|
+
if (k.includes("STRIPE") || k.includes("PAYMENT") || k.includes("PAYPAL"))
|
|
514
|
+
return "payment";
|
|
515
|
+
if (k.includes("VERCEL") || k.includes("AWS") || k.includes("GCP") || k.includes("AZURE") || k.includes("HEROKU") || k.includes("DEPLOY"))
|
|
516
|
+
return "hosting";
|
|
517
|
+
if (k.includes("SMTP") || k.includes("EMAIL") || k.includes("SENDGRID") || k.includes("RESEND") || k.includes("MAILGUN"))
|
|
518
|
+
return "email";
|
|
519
|
+
if (k.includes("ANALYTICS") || k.includes("MIXPANEL") || k.includes("GA_") || k.includes("POSTHOG") || k.includes("SENTRY"))
|
|
520
|
+
return "analytics";
|
|
521
|
+
return "general";
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// src/commands/push.ts
|
|
525
|
+
import { Command as Command4 } from "commander";
|
|
526
|
+
import chalk4 from "chalk";
|
|
527
|
+
var pushCommand = new Command4("push").description("Push local .env to vault").action(async () => {
|
|
528
|
+
let config;
|
|
529
|
+
try {
|
|
530
|
+
config = requireAuth();
|
|
531
|
+
} catch (err) {
|
|
532
|
+
error(err.message);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
let projectConfig;
|
|
536
|
+
try {
|
|
537
|
+
projectConfig = requireProjectConfig();
|
|
538
|
+
} catch (err) {
|
|
539
|
+
error(err.message);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
const envData = readEnvFile();
|
|
543
|
+
if (!envData) {
|
|
544
|
+
warning("No .env file found in current directory.");
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
const vars = envData.vars;
|
|
548
|
+
const varCount = Object.keys(vars).length;
|
|
549
|
+
if (varCount === 0) {
|
|
550
|
+
warning("No variables found in .env file.");
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
let projectPassword = null;
|
|
554
|
+
try {
|
|
555
|
+
const projects = await getProjects();
|
|
556
|
+
const project = projects.find((p) => p.id === projectConfig.projectId);
|
|
557
|
+
if (project?.has_project_password) {
|
|
558
|
+
projectPassword = await promptSecret("Project password: ");
|
|
559
|
+
const blob = JSON.parse(project.project_password_salt);
|
|
560
|
+
try {
|
|
561
|
+
const result = decrypt(blob.encrypted, blob.iv, blob.salt, blob.authTag, projectPassword);
|
|
562
|
+
if (result !== "keyra-verified") {
|
|
563
|
+
error("Incorrect project password");
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
} catch {
|
|
567
|
+
error("Incorrect project password");
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
} catch (err) {
|
|
572
|
+
error("Failed to fetch project info: " + err.message);
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
const s = spinner("Encrypting and uploading " + varCount + " variable" + (varCount !== 1 ? "s" : "") + "...");
|
|
576
|
+
s.start();
|
|
577
|
+
try {
|
|
578
|
+
const entries = Object.entries(vars).map(([key, value]) => {
|
|
579
|
+
let encrypted;
|
|
580
|
+
if (projectPassword) {
|
|
581
|
+
const inner = encrypt(value, projectPassword);
|
|
582
|
+
const innerJson = JSON.stringify(inner);
|
|
583
|
+
encrypted = encrypt(innerJson, config.passphrase);
|
|
584
|
+
} else {
|
|
585
|
+
encrypted = encrypt(value, config.passphrase);
|
|
586
|
+
}
|
|
587
|
+
return {
|
|
588
|
+
key_name: key,
|
|
589
|
+
encrypted_value: encrypted.encrypted,
|
|
590
|
+
iv: encrypted.iv,
|
|
591
|
+
salt: encrypted.salt,
|
|
592
|
+
auth_tag: encrypted.authTag,
|
|
593
|
+
category: guessCategory2(key)
|
|
594
|
+
};
|
|
595
|
+
});
|
|
596
|
+
await upsertVaultEntries(projectConfig.projectId, entries);
|
|
597
|
+
s.stop();
|
|
598
|
+
success(
|
|
599
|
+
"Pushed " + varCount + " variable" + (varCount !== 1 ? "s" : "") + " to vault " + chalk4.dim("(" + projectConfig.projectName + ")")
|
|
600
|
+
);
|
|
601
|
+
} catch (err) {
|
|
602
|
+
s.stop();
|
|
603
|
+
error(err.message);
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
function guessCategory2(key) {
|
|
607
|
+
const k = key.toUpperCase();
|
|
608
|
+
if (k.includes("OPENAI") || k.includes("ANTHROPIC") || k.includes("AI"))
|
|
609
|
+
return "ai";
|
|
610
|
+
if (k.includes("DATABASE") || k.includes("DB_") || k.includes("POSTGRES") || k.includes("MYSQL") || k.includes("MONGO") || k.includes("REDIS") || k.includes("SUPABASE"))
|
|
611
|
+
return "database";
|
|
612
|
+
if (k.includes("AUTH") || k.includes("JWT") || k.includes("SESSION") || k.includes("NEXTAUTH") || k.includes("CLERK"))
|
|
613
|
+
return "auth";
|
|
614
|
+
if (k.includes("STRIPE") || k.includes("PAYMENT") || k.includes("PAYPAL"))
|
|
615
|
+
return "payment";
|
|
616
|
+
if (k.includes("VERCEL") || k.includes("AWS") || k.includes("GCP") || k.includes("AZURE") || k.includes("HEROKU"))
|
|
617
|
+
return "hosting";
|
|
618
|
+
if (k.includes("SMTP") || k.includes("EMAIL") || k.includes("SENDGRID") || k.includes("RESEND") || k.includes("MAILGUN"))
|
|
619
|
+
return "email";
|
|
620
|
+
if (k.includes("ANALYTICS") || k.includes("MIXPANEL") || k.includes("GA_") || k.includes("POSTHOG") || k.includes("SENTRY"))
|
|
621
|
+
return "analytics";
|
|
622
|
+
return "general";
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// src/commands/pull.ts
|
|
626
|
+
import { Command as Command5 } from "commander";
|
|
627
|
+
import fs3 from "fs";
|
|
628
|
+
import path4 from "path";
|
|
629
|
+
import chalk5 from "chalk";
|
|
630
|
+
var pullCommand = new Command5("pull").description("Pull vault variables to local .env").option("-f, --file <filename>", "Output filename", ".env").action(async (options) => {
|
|
631
|
+
let config;
|
|
632
|
+
try {
|
|
633
|
+
config = requireAuth();
|
|
634
|
+
} catch (err) {
|
|
635
|
+
error(err.message);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
let projectConfig;
|
|
639
|
+
try {
|
|
640
|
+
projectConfig = requireProjectConfig();
|
|
641
|
+
} catch (err) {
|
|
642
|
+
error(err.message);
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
let projectPassword = null;
|
|
646
|
+
try {
|
|
647
|
+
const projects = await getProjects();
|
|
648
|
+
const project = projects.find((p) => p.id === projectConfig.projectId);
|
|
649
|
+
if (project?.has_project_password) {
|
|
650
|
+
projectPassword = await promptSecret("Project password: ");
|
|
651
|
+
const blob = JSON.parse(project.project_password_salt);
|
|
652
|
+
try {
|
|
653
|
+
const result = decrypt(blob.encrypted, blob.iv, blob.salt, blob.authTag, projectPassword);
|
|
654
|
+
if (result !== "keyra-verified") {
|
|
655
|
+
error("Incorrect project password");
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
} catch {
|
|
659
|
+
error("Incorrect project password");
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
} catch (err) {
|
|
664
|
+
error("Failed to fetch project info: " + err.message);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
const s = spinner("Pulling from vault...");
|
|
668
|
+
s.start();
|
|
669
|
+
try {
|
|
670
|
+
const entries = await getVaultEntries(projectConfig.projectId);
|
|
671
|
+
if (entries.length === 0) {
|
|
672
|
+
s.stop();
|
|
673
|
+
warning("No variables found in vault. Push some first with `keyra-cli push`.");
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
const vars = {};
|
|
677
|
+
for (const entry of entries) {
|
|
678
|
+
if (projectPassword) {
|
|
679
|
+
const innerJson = decrypt(entry.encrypted_value, entry.iv, entry.salt, entry.auth_tag, config.passphrase);
|
|
680
|
+
const inner = JSON.parse(innerJson);
|
|
681
|
+
vars[entry.key_name] = decrypt(inner.encrypted, inner.iv, inner.salt, inner.authTag, projectPassword);
|
|
682
|
+
} else {
|
|
683
|
+
vars[entry.key_name] = decrypt(entry.encrypted_value, entry.iv, entry.salt, entry.auth_tag, config.passphrase);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
const existed = fs3.existsSync(path4.join(process.cwd(), options.file));
|
|
687
|
+
writeEnvFile(vars, void 0, options.file);
|
|
688
|
+
s.stop();
|
|
689
|
+
success(
|
|
690
|
+
"Pulled " + entries.length + " variable" + (entries.length !== 1 ? "s" : "") + " from vault -> " + options.file + " " + chalk5.dim("(" + projectConfig.projectName + ")")
|
|
691
|
+
);
|
|
692
|
+
if (existed) {
|
|
693
|
+
warning("Existing " + options.file + " was overwritten");
|
|
694
|
+
}
|
|
695
|
+
} catch (err) {
|
|
696
|
+
s.stop();
|
|
697
|
+
error(err.message);
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
// src/commands/validate.ts
|
|
702
|
+
import { Command as Command6 } from "commander";
|
|
703
|
+
import chalk6 from "chalk";
|
|
704
|
+
var validateCommand = new Command6("validate").description("Check local .env has all required vault variables").action(async () => {
|
|
705
|
+
let projectConfig;
|
|
706
|
+
try {
|
|
707
|
+
projectConfig = requireProjectConfig();
|
|
708
|
+
} catch (err) {
|
|
709
|
+
error(err.message);
|
|
710
|
+
process.exit(1);
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
let config;
|
|
714
|
+
try {
|
|
715
|
+
config = requireAuth();
|
|
716
|
+
} catch (err) {
|
|
717
|
+
error(err.message);
|
|
718
|
+
process.exit(1);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
const s = spinner("Checking vault...");
|
|
722
|
+
s.start();
|
|
723
|
+
let entries;
|
|
724
|
+
try {
|
|
725
|
+
entries = await getVaultEntries(projectConfig.projectId);
|
|
726
|
+
s.stop();
|
|
727
|
+
} catch (err) {
|
|
728
|
+
s.stop();
|
|
729
|
+
error(err.message);
|
|
730
|
+
process.exit(1);
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
if (entries.length === 0) {
|
|
734
|
+
warning("No variables in vault to validate against.");
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
const envData = readEnvFile();
|
|
738
|
+
const localVars = envData?.vars ?? {};
|
|
739
|
+
const rows = [];
|
|
740
|
+
let passed = 0;
|
|
741
|
+
let issues = 0;
|
|
742
|
+
for (const entry of entries) {
|
|
743
|
+
const localValue = localVars[entry.key_name];
|
|
744
|
+
if (localValue === void 0) {
|
|
745
|
+
rows.push({
|
|
746
|
+
key: entry.key_name,
|
|
747
|
+
value: "MISSING",
|
|
748
|
+
status: "missing"
|
|
749
|
+
});
|
|
750
|
+
issues++;
|
|
751
|
+
} else if (localValue.trim() === "") {
|
|
752
|
+
rows.push({
|
|
753
|
+
key: entry.key_name,
|
|
754
|
+
value: "EMPTY",
|
|
755
|
+
status: "empty"
|
|
756
|
+
});
|
|
757
|
+
issues++;
|
|
758
|
+
} else {
|
|
759
|
+
rows.push({
|
|
760
|
+
key: entry.key_name,
|
|
761
|
+
value: "present",
|
|
762
|
+
status: "present"
|
|
763
|
+
});
|
|
764
|
+
passed++;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
console.log();
|
|
768
|
+
printTable(rows);
|
|
769
|
+
console.log();
|
|
770
|
+
const total = entries.length;
|
|
771
|
+
if (issues === 0) {
|
|
772
|
+
console.log(
|
|
773
|
+
chalk6.green.bold(` ${passed} of ${total} checks passed. All good!`)
|
|
774
|
+
);
|
|
775
|
+
} else {
|
|
776
|
+
console.log(
|
|
777
|
+
chalk6.yellow(
|
|
778
|
+
` ${passed} of ${total} checks passed. ${issues} issue${issues !== 1 ? "s" : ""} found.`
|
|
779
|
+
)
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
console.log();
|
|
783
|
+
process.exit(issues > 0 ? 1 : 0);
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
// src/commands/share.ts
|
|
787
|
+
import { Command as Command7 } from "commander";
|
|
788
|
+
import crypto2 from "crypto";
|
|
789
|
+
import chalk7 from "chalk";
|
|
790
|
+
var shareCommand = new Command7("share").description("Create an encrypted share link for your .env").action(async () => {
|
|
791
|
+
let config;
|
|
792
|
+
try {
|
|
793
|
+
config = requireAuth();
|
|
794
|
+
} catch (err) {
|
|
795
|
+
error(err.message);
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
let projectConfig;
|
|
799
|
+
try {
|
|
800
|
+
projectConfig = requireProjectConfig();
|
|
801
|
+
} catch (err) {
|
|
802
|
+
error(err.message);
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
const envData = readEnvFile();
|
|
806
|
+
if (!envData) {
|
|
807
|
+
warning("No .env file found in current directory.");
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
const varCount = Object.keys(envData.vars).length;
|
|
811
|
+
if (varCount === 0) {
|
|
812
|
+
warning("No variables found in .env file.");
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
const s = spinner("Creating share link...");
|
|
816
|
+
s.start();
|
|
817
|
+
try {
|
|
818
|
+
const oneTimeKey = crypto2.randomBytes(32).toString("hex");
|
|
819
|
+
const envContent = toEnvFileString(envData.vars);
|
|
820
|
+
const encrypted = encrypt(envContent, oneTimeKey);
|
|
821
|
+
const result = await createShareLink({
|
|
822
|
+
project_id: projectConfig.projectId,
|
|
823
|
+
encrypted_data: encrypted.encrypted,
|
|
824
|
+
iv: encrypted.iv,
|
|
825
|
+
salt: encrypted.salt,
|
|
826
|
+
auth_tag: encrypted.authTag
|
|
827
|
+
});
|
|
828
|
+
s.stop();
|
|
829
|
+
const shareUrl = result.url || `${config.apiUrl}/share/${result.token}`;
|
|
830
|
+
printBox(
|
|
831
|
+
chalk7.green.bold("Share link created!") + chalk7.dim(` (${varCount} variables)`) + "\n\n" + chalk7.white.bold("Link ") + chalk7.dim("(expires in 24h, viewable once):") + "\n" + chalk7.cyan(shareUrl) + "\n\n" + chalk7.white.bold("Decryption key ") + chalk7.dim("(send separately!):") + "\n" + chalk7.yellow(oneTimeKey) + "\n\n" + chalk7.yellow("\u26A0 The link and key should be sent via different channels for security.")
|
|
832
|
+
);
|
|
833
|
+
} catch (err) {
|
|
834
|
+
s.stop();
|
|
835
|
+
error(err.message);
|
|
836
|
+
}
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
// src/commands/list.ts
|
|
840
|
+
import { Command as Command8 } from "commander";
|
|
841
|
+
import chalk8 from "chalk";
|
|
842
|
+
var listCommand = new Command8("list").alias("ls").description("List all your projects").action(async () => {
|
|
843
|
+
try {
|
|
844
|
+
requireAuth();
|
|
845
|
+
} catch (err) {
|
|
846
|
+
error(err.message);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
const s = spinner("Loading projects...");
|
|
850
|
+
s.start();
|
|
851
|
+
try {
|
|
852
|
+
const projects = await getProjects();
|
|
853
|
+
s.stop();
|
|
854
|
+
if (projects.length === 0) {
|
|
855
|
+
info("No projects yet. Run `keyra-cli init` in a project folder.");
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
console.log();
|
|
859
|
+
console.log(chalk8.bold(` ${projects.length} project${projects.length !== 1 ? "s" : ""}:`));
|
|
860
|
+
console.log();
|
|
861
|
+
const maxName = Math.max(...projects.map((p) => p.name.length), 4);
|
|
862
|
+
console.log(
|
|
863
|
+
chalk8.dim(
|
|
864
|
+
` ${"Name".padEnd(maxName + 2)}${"Vars".padEnd(8)}Last Synced`
|
|
865
|
+
)
|
|
866
|
+
);
|
|
867
|
+
console.log(chalk8.dim(` ${"\u2500".repeat(maxName + 2 + 8 + 20)}`));
|
|
868
|
+
for (const project of projects) {
|
|
869
|
+
const name = chalk8.green(project.name.padEnd(maxName + 2));
|
|
870
|
+
const vars = String(project.var_count).padEnd(8);
|
|
871
|
+
const synced = project.last_synced_at ? new Date(project.last_synced_at).toLocaleDateString() : chalk8.dim("never");
|
|
872
|
+
console.log(` ${name}${vars}${synced}`);
|
|
873
|
+
}
|
|
874
|
+
console.log();
|
|
875
|
+
} catch (err) {
|
|
876
|
+
s.stop();
|
|
877
|
+
error(err.message);
|
|
878
|
+
}
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
// src/commands/status.ts
|
|
882
|
+
import { Command as Command9 } from "commander";
|
|
883
|
+
import chalk9 from "chalk";
|
|
884
|
+
var statusCommand = new Command9("status").description("Show current project status").action(async () => {
|
|
885
|
+
const projectConfig = loadProjectConfig();
|
|
886
|
+
if (!projectConfig) {
|
|
887
|
+
info("Not in a Keyra project. Run `keyra-cli init` first.");
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
console.log();
|
|
891
|
+
console.log(chalk9.bold(" Keyra Status"));
|
|
892
|
+
console.log(chalk9.dim(` ${"\u2500".repeat(40)}`));
|
|
893
|
+
console.log(` ${chalk9.dim("Project:")} ${chalk9.green(projectConfig.projectName)}`);
|
|
894
|
+
console.log(` ${chalk9.dim("ID:")} ${chalk9.dim(projectConfig.projectId)}`);
|
|
895
|
+
if (!isLoggedIn()) {
|
|
896
|
+
console.log();
|
|
897
|
+
info("Not logged in. Run `keyra-cli login` to sync.");
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
let config;
|
|
901
|
+
try {
|
|
902
|
+
config = requireAuth();
|
|
903
|
+
} catch (err) {
|
|
904
|
+
error(err.message);
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
const s = spinner("Checking vault...");
|
|
908
|
+
s.start();
|
|
909
|
+
try {
|
|
910
|
+
const entries = await getVaultEntries(projectConfig.projectId);
|
|
911
|
+
s.stop();
|
|
912
|
+
console.log(` ${chalk9.dim("Vault:")} ${entries.length} variable${entries.length !== 1 ? "s" : ""}`);
|
|
913
|
+
const envData = readEnvFile();
|
|
914
|
+
if (envData) {
|
|
915
|
+
const localKeys = new Set(Object.keys(envData.vars));
|
|
916
|
+
const vaultKeys = new Set(entries.map((e) => e.key_name));
|
|
917
|
+
const onlyLocal = [...localKeys].filter((k) => !vaultKeys.has(k));
|
|
918
|
+
const onlyVault = [...vaultKeys].filter((k) => !localKeys.has(k));
|
|
919
|
+
const inBoth = [...localKeys].filter((k) => vaultKeys.has(k));
|
|
920
|
+
console.log(` ${chalk9.dim("Local:")} ${localKeys.size} variable${localKeys.size !== 1 ? "s" : ""} (${envData.filename})`);
|
|
921
|
+
console.log();
|
|
922
|
+
if (onlyLocal.length === 0 && onlyVault.length === 0) {
|
|
923
|
+
console.log(chalk9.green(" \u2713 Local and vault keys are in sync"));
|
|
924
|
+
} else {
|
|
925
|
+
if (onlyLocal.length > 0) {
|
|
926
|
+
console.log(
|
|
927
|
+
chalk9.yellow(
|
|
928
|
+
` \u26A0 ${onlyLocal.length} key${onlyLocal.length !== 1 ? "s" : ""} only in local: ${onlyLocal.join(", ")}`
|
|
929
|
+
)
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
if (onlyVault.length > 0) {
|
|
933
|
+
console.log(
|
|
934
|
+
chalk9.yellow(
|
|
935
|
+
` \u26A0 ${onlyVault.length} key${onlyVault.length !== 1 ? "s" : ""} only in vault: ${onlyVault.join(", ")}`
|
|
936
|
+
)
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
} else {
|
|
941
|
+
console.log(` ${chalk9.dim("Local:")} ${chalk9.yellow("no .env file found")}`);
|
|
942
|
+
}
|
|
943
|
+
console.log();
|
|
944
|
+
} catch (err) {
|
|
945
|
+
s.stop();
|
|
946
|
+
error(err.message);
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
// src/commands/guard.ts
|
|
951
|
+
import { Command as Command10 } from "commander";
|
|
952
|
+
import fs4 from "fs";
|
|
953
|
+
import path5 from "path";
|
|
954
|
+
import chalk10 from "chalk";
|
|
955
|
+
var HOOK_MARKER = "# keyra-guard";
|
|
956
|
+
var PRE_COMMIT_HOOK = `#!/bin/sh
|
|
957
|
+
${HOOK_MARKER}
|
|
958
|
+
# Keyra Git Guardian \u2014 blocks accidental .env commits
|
|
959
|
+
# https://keyra.dev
|
|
960
|
+
|
|
961
|
+
staged=$(git diff --cached --name-only 2>/dev/null)
|
|
962
|
+
|
|
963
|
+
for file in $staged; do
|
|
964
|
+
if echo "$file" | grep -qE '(^|\\.)\\.env(\\..*)?$'; then
|
|
965
|
+
echo ""
|
|
966
|
+
echo " \\033[0;31m\u2717 Keyra Git Guardian blocked your commit\\033[0m"
|
|
967
|
+
echo " \\033[0;33m\u26A0 Detected .env file in staged changes: $file\\033[0m"
|
|
968
|
+
echo ""
|
|
969
|
+
echo " Commit secrets to your Keyra vault instead:"
|
|
970
|
+
echo " keyra-cli push"
|
|
971
|
+
echo ""
|
|
972
|
+
exit 1
|
|
973
|
+
fi
|
|
974
|
+
done
|
|
975
|
+
`;
|
|
976
|
+
var guardCommand = new Command10("guard").description("Install a pre-commit hook to block accidental .env commits").action(async () => {
|
|
977
|
+
let gitDir = null;
|
|
978
|
+
let dir = process.cwd();
|
|
979
|
+
for (let i = 0; i < 10; i++) {
|
|
980
|
+
const candidate = path5.join(dir, ".git");
|
|
981
|
+
if (fs4.existsSync(candidate) && fs4.statSync(candidate).isDirectory()) {
|
|
982
|
+
gitDir = candidate;
|
|
983
|
+
break;
|
|
984
|
+
}
|
|
985
|
+
const parent = path5.dirname(dir);
|
|
986
|
+
if (parent === dir) break;
|
|
987
|
+
dir = parent;
|
|
988
|
+
}
|
|
989
|
+
if (!gitDir) {
|
|
990
|
+
error("Not inside a git repository.");
|
|
991
|
+
process.exit(1);
|
|
992
|
+
}
|
|
993
|
+
const hooksDir = path5.join(gitDir, "hooks");
|
|
994
|
+
if (!fs4.existsSync(hooksDir)) {
|
|
995
|
+
fs4.mkdirSync(hooksDir, { recursive: true });
|
|
996
|
+
}
|
|
997
|
+
const hookPath = path5.join(hooksDir, "pre-commit");
|
|
998
|
+
if (fs4.existsSync(hookPath)) {
|
|
999
|
+
const existing = fs4.readFileSync(hookPath, "utf-8");
|
|
1000
|
+
if (existing.includes(HOOK_MARKER)) {
|
|
1001
|
+
warning("Git Guardian is already installed in this repository.");
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
fs4.appendFileSync(hookPath, "\n" + PRE_COMMIT_HOOK);
|
|
1005
|
+
success("Git Guardian appended to existing pre-commit hook.");
|
|
1006
|
+
} else {
|
|
1007
|
+
fs4.writeFileSync(hookPath, PRE_COMMIT_HOOK);
|
|
1008
|
+
}
|
|
1009
|
+
try {
|
|
1010
|
+
fs4.chmodSync(hookPath, 493);
|
|
1011
|
+
} catch {
|
|
1012
|
+
}
|
|
1013
|
+
printBox(
|
|
1014
|
+
chalk10.green.bold("Git Guardian installed") + "\n\n" + chalk10.dim("Pre-commit hook added to: ") + chalk10.white(hookPath) + "\n\n" + chalk10.dim("Now if you accidentally stage a .env file,\n") + chalk10.dim("git commit will be blocked automatically."),
|
|
1015
|
+
"green"
|
|
1016
|
+
);
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
// src/commands/scan.ts
|
|
1020
|
+
import { Command as Command11 } from "commander";
|
|
1021
|
+
import fs5 from "fs";
|
|
1022
|
+
import path6 from "path";
|
|
1023
|
+
import crypto3 from "crypto";
|
|
1024
|
+
import chalk11 from "chalk";
|
|
1025
|
+
async function checkHibp(value) {
|
|
1026
|
+
const hash = crypto3.createHash("sha1").update(value).digest("hex").toUpperCase();
|
|
1027
|
+
const prefix = hash.slice(0, 5);
|
|
1028
|
+
const suffix = hash.slice(5);
|
|
1029
|
+
try {
|
|
1030
|
+
const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {
|
|
1031
|
+
headers: { "Add-Padding": "true" }
|
|
1032
|
+
});
|
|
1033
|
+
if (!res.ok) return 0;
|
|
1034
|
+
const text = await res.text();
|
|
1035
|
+
for (const line of text.split("\n")) {
|
|
1036
|
+
const [lineSuffix, countStr] = line.trim().split(":");
|
|
1037
|
+
if (lineSuffix === suffix) return parseInt(countStr, 10) || 0;
|
|
1038
|
+
}
|
|
1039
|
+
return 0;
|
|
1040
|
+
} catch {
|
|
1041
|
+
return 0;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
async function parseEnvFile2(filePath) {
|
|
1045
|
+
const content = fs5.readFileSync(filePath, "utf-8");
|
|
1046
|
+
const result = {};
|
|
1047
|
+
for (const line of content.split("\n")) {
|
|
1048
|
+
const trimmed = line.trim();
|
|
1049
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
1050
|
+
const eqIdx = trimmed.indexOf("=");
|
|
1051
|
+
if (eqIdx === -1) continue;
|
|
1052
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
1053
|
+
const value = trimmed.slice(eqIdx + 1).trim().replace(/^['"]|['"]$/g, "");
|
|
1054
|
+
if (key && value) result[key] = value;
|
|
1055
|
+
}
|
|
1056
|
+
return result;
|
|
1057
|
+
}
|
|
1058
|
+
var scanCommand = new Command11("scan").description("Check secrets for known data breaches using HaveIBeenPwned").option("-f, --file <path>", "Path to .env file to scan", ".env").option("--project <id>", "Scan a vault project instead of a local file").action(async (opts) => {
|
|
1059
|
+
const s = spinner("Preparing scan...").start();
|
|
1060
|
+
let secrets = {};
|
|
1061
|
+
if (opts.project) {
|
|
1062
|
+
const config = requireAuth();
|
|
1063
|
+
try {
|
|
1064
|
+
const entries = await apiGet(`/api/vault?project_id=${opts.project}`);
|
|
1065
|
+
for (const entry of entries) {
|
|
1066
|
+
try {
|
|
1067
|
+
const value = decrypt(
|
|
1068
|
+
entry.encrypted_value,
|
|
1069
|
+
entry.iv,
|
|
1070
|
+
entry.salt,
|
|
1071
|
+
entry.auth_tag,
|
|
1072
|
+
config.passphrase
|
|
1073
|
+
);
|
|
1074
|
+
secrets[entry.key_name] = value;
|
|
1075
|
+
} catch {
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
} catch (err) {
|
|
1079
|
+
s.fail("Failed to fetch vault entries.");
|
|
1080
|
+
error(err.message);
|
|
1081
|
+
process.exit(1);
|
|
1082
|
+
}
|
|
1083
|
+
} else {
|
|
1084
|
+
const filePath = path6.resolve(process.cwd(), opts.file);
|
|
1085
|
+
if (!fs5.existsSync(filePath)) {
|
|
1086
|
+
s.fail(`File not found: ${filePath}`);
|
|
1087
|
+
process.exit(1);
|
|
1088
|
+
}
|
|
1089
|
+
try {
|
|
1090
|
+
secrets = await parseEnvFile2(filePath);
|
|
1091
|
+
} catch {
|
|
1092
|
+
s.fail(`Failed to read ${opts.file}`);
|
|
1093
|
+
process.exit(1);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
const keys = Object.keys(secrets);
|
|
1097
|
+
if (keys.length === 0) {
|
|
1098
|
+
s.stop();
|
|
1099
|
+
info("No secrets found to scan.");
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
s.text = `Scanning ${keys.length} secret${keys.length === 1 ? "" : "s"} against breach databases...`;
|
|
1103
|
+
const breached = [];
|
|
1104
|
+
for (const key of keys) {
|
|
1105
|
+
const value = secrets[key];
|
|
1106
|
+
if (!value || value.length < 8) continue;
|
|
1107
|
+
const count = await checkHibp(value);
|
|
1108
|
+
if (count > 0) {
|
|
1109
|
+
breached.push({ key, count });
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
s.stop();
|
|
1113
|
+
console.log("");
|
|
1114
|
+
if (breached.length === 0) {
|
|
1115
|
+
printBox(
|
|
1116
|
+
chalk11.green.bold("No breaches detected") + "\n\n" + chalk11.dim(`Scanned ${keys.length} secret${keys.length === 1 ? "" : "s"} \xB7 0 found in breach databases`),
|
|
1117
|
+
"green"
|
|
1118
|
+
);
|
|
1119
|
+
} else {
|
|
1120
|
+
printBox(
|
|
1121
|
+
chalk11.red.bold(`${breached.length} secret${breached.length === 1 ? "" : "s"} found in breach databases`) + "\n\n" + breached.map(
|
|
1122
|
+
({ key, count }) => chalk11.red("\u2717") + " " + chalk11.bold(key) + chalk11.dim(` \u2014 seen ${count.toLocaleString()} times`)
|
|
1123
|
+
).join("\n") + "\n\n" + chalk11.yellow("Rotate these secrets immediately."),
|
|
1124
|
+
"red"
|
|
1125
|
+
);
|
|
1126
|
+
process.exit(1);
|
|
1127
|
+
}
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
// src/index.ts
|
|
1131
|
+
var program = new Command12();
|
|
1132
|
+
program.name("keyra-cli").description("Encrypted .env vault. Sync, share, never lose an API key.").version("0.1.0");
|
|
1133
|
+
program.addCommand(loginCommand);
|
|
1134
|
+
program.addCommand(logoutCommand);
|
|
1135
|
+
program.addCommand(initCommand);
|
|
1136
|
+
program.addCommand(pushCommand);
|
|
1137
|
+
program.addCommand(pullCommand);
|
|
1138
|
+
program.addCommand(validateCommand);
|
|
1139
|
+
program.addCommand(shareCommand);
|
|
1140
|
+
program.addCommand(listCommand);
|
|
1141
|
+
program.addCommand(statusCommand);
|
|
1142
|
+
program.addCommand(guardCommand);
|
|
1143
|
+
program.addCommand(scanCommand);
|
|
1144
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
1145
|
+
console.error(chalk12.red("Error:"), err.message);
|
|
1146
|
+
process.exit(1);
|
|
1147
|
+
});
|