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.
@@ -0,0 +1,145 @@
1
+ import { Command } from 'commander';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import crypto from 'crypto';
5
+ import chalk from 'chalk';
6
+ import { success, error, warning, info, spinner, printBox } from '../lib/ui.js';
7
+ import { requireAuth } from '../lib/config.js';
8
+ import { decrypt } from '../lib/encryption.js';
9
+ import { apiGet } from '../lib/api.js';
10
+
11
+ async function checkHibp(value: string): Promise<number> {
12
+ const hash = crypto.createHash('sha1').update(value).digest('hex').toUpperCase();
13
+ const prefix = hash.slice(0, 5);
14
+ const suffix = hash.slice(5);
15
+
16
+ try {
17
+ const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {
18
+ headers: { 'Add-Padding': 'true' },
19
+ });
20
+ if (!res.ok) return 0;
21
+ const text = await res.text();
22
+ for (const line of text.split('\n')) {
23
+ const [lineSuffix, countStr] = line.trim().split(':');
24
+ if (lineSuffix === suffix) return parseInt(countStr, 10) || 0;
25
+ }
26
+ return 0;
27
+ } catch {
28
+ return 0;
29
+ }
30
+ }
31
+
32
+ async function parseEnvFile(filePath: string): Promise<Record<string, string>> {
33
+ const content = fs.readFileSync(filePath, 'utf-8');
34
+ const result: Record<string, string> = {};
35
+ for (const line of content.split('\n')) {
36
+ const trimmed = line.trim();
37
+ if (!trimmed || trimmed.startsWith('#')) continue;
38
+ const eqIdx = trimmed.indexOf('=');
39
+ if (eqIdx === -1) continue;
40
+ const key = trimmed.slice(0, eqIdx).trim();
41
+ const value = trimmed.slice(eqIdx + 1).trim().replace(/^['"]|['"]$/g, '');
42
+ if (key && value) result[key] = value;
43
+ }
44
+ return result;
45
+ }
46
+
47
+ export const scanCommand = new Command('scan')
48
+ .description('Check secrets for known data breaches using HaveIBeenPwned')
49
+ .option('-f, --file <path>', 'Path to .env file to scan', '.env')
50
+ .option('--project <id>', 'Scan a vault project instead of a local file')
51
+ .action(async (opts) => {
52
+ const s = spinner('Preparing scan...').start();
53
+
54
+ let secrets: Record<string, string> = {};
55
+
56
+ if (opts.project) {
57
+ // Scan from vault
58
+ const config = requireAuth();
59
+ try {
60
+ const entries = await apiGet(`/api/vault?project_id=${opts.project}`);
61
+ for (const entry of entries) {
62
+ try {
63
+ const value = decrypt(
64
+ entry.encrypted_value,
65
+ entry.iv,
66
+ entry.salt,
67
+ entry.auth_tag,
68
+ config.passphrase
69
+ );
70
+ secrets[entry.key_name] = value;
71
+ } catch {
72
+ // skip undecrytable entries
73
+ }
74
+ }
75
+ } catch (err: any) {
76
+ s.fail('Failed to fetch vault entries.');
77
+ error(err.message);
78
+ process.exit(1);
79
+ }
80
+ } else {
81
+ // Scan local .env file
82
+ const filePath = path.resolve(process.cwd(), opts.file);
83
+ if (!fs.existsSync(filePath)) {
84
+ s.fail(`File not found: ${filePath}`);
85
+ process.exit(1);
86
+ }
87
+ try {
88
+ secrets = await parseEnvFile(filePath);
89
+ } catch {
90
+ s.fail(`Failed to read ${opts.file}`);
91
+ process.exit(1);
92
+ }
93
+ }
94
+
95
+ const keys = Object.keys(secrets);
96
+ if (keys.length === 0) {
97
+ s.stop();
98
+ info('No secrets found to scan.');
99
+ return;
100
+ }
101
+
102
+ s.text = `Scanning ${keys.length} secret${keys.length === 1 ? '' : 's'} against breach databases...`;
103
+
104
+ const breached: { key: string; count: number }[] = [];
105
+
106
+ for (const key of keys) {
107
+ const value = secrets[key];
108
+ // Skip empty or very short values (env flags, etc.)
109
+ if (!value || value.length < 8) continue;
110
+ const count = await checkHibp(value);
111
+ if (count > 0) {
112
+ breached.push({ key, count });
113
+ }
114
+ }
115
+
116
+ s.stop();
117
+ console.log('');
118
+
119
+ if (breached.length === 0) {
120
+ printBox(
121
+ chalk.green.bold('No breaches detected') +
122
+ '\n\n' +
123
+ chalk.dim(`Scanned ${keys.length} secret${keys.length === 1 ? '' : 's'} · 0 found in breach databases`),
124
+ 'green'
125
+ );
126
+ } else {
127
+ printBox(
128
+ chalk.red.bold(`${breached.length} secret${breached.length === 1 ? '' : 's'} found in breach databases`) +
129
+ '\n\n' +
130
+ breached
131
+ .map(
132
+ ({ key, count }) =>
133
+ chalk.red('✗') +
134
+ ' ' +
135
+ chalk.bold(key) +
136
+ chalk.dim(` — seen ${count.toLocaleString()} times`)
137
+ )
138
+ .join('\n') +
139
+ '\n\n' +
140
+ chalk.yellow('Rotate these secrets immediately.'),
141
+ 'red'
142
+ );
143
+ process.exit(1);
144
+ }
145
+ });
@@ -0,0 +1,84 @@
1
+ import { Command } from 'commander';
2
+ import crypto from 'crypto';
3
+ import chalk from 'chalk';
4
+ import { requireAuth, requireProjectConfig } from '../lib/config.js';
5
+ import { createShareLink } from '../lib/api.js';
6
+ import { readEnvFile, toEnvFileString } from '../lib/env-file.js';
7
+ import { encrypt } from '../lib/encryption.js';
8
+ import { error, warning, spinner, printBox } from '../lib/ui.js';
9
+
10
+ export const shareCommand = new Command('share')
11
+ .description('Create an encrypted share link for your .env')
12
+ .action(async () => {
13
+ let config;
14
+ try {
15
+ config = requireAuth();
16
+ } catch (err: any) {
17
+ error(err.message);
18
+ return;
19
+ }
20
+
21
+ let projectConfig;
22
+ try {
23
+ projectConfig = requireProjectConfig();
24
+ } catch (err: any) {
25
+ error(err.message);
26
+ return;
27
+ }
28
+
29
+ const envData = readEnvFile();
30
+ if (!envData) {
31
+ warning('No .env file found in current directory.');
32
+ return;
33
+ }
34
+
35
+ const varCount = Object.keys(envData.vars).length;
36
+ if (varCount === 0) {
37
+ warning('No variables found in .env file.');
38
+ return;
39
+ }
40
+
41
+ const s = spinner('Creating share link...');
42
+ s.start();
43
+
44
+ try {
45
+ // Generate a one-time passphrase (NOT the user's vault passphrase)
46
+ const oneTimeKey = crypto.randomBytes(32).toString('hex');
47
+
48
+ // Encrypt the entire .env content with the one-time key
49
+ const envContent = toEnvFileString(envData.vars);
50
+ const encrypted = encrypt(envContent, oneTimeKey);
51
+
52
+ const result = await createShareLink({
53
+ project_id: projectConfig.projectId,
54
+ encrypted_data: encrypted.encrypted,
55
+ iv: encrypted.iv,
56
+ salt: encrypted.salt,
57
+ auth_tag: encrypted.authTag,
58
+ });
59
+
60
+ s.stop();
61
+
62
+ const shareUrl = result.url || `${config.apiUrl}/share/${result.token}`;
63
+
64
+ printBox(
65
+ chalk.green.bold('Share link created!') +
66
+ chalk.dim(` (${varCount} variables)`) +
67
+ '\n\n' +
68
+ chalk.white.bold('Link ') +
69
+ chalk.dim('(expires in 24h, viewable once):') +
70
+ '\n' +
71
+ chalk.cyan(shareUrl) +
72
+ '\n\n' +
73
+ chalk.white.bold('Decryption key ') +
74
+ chalk.dim('(send separately!):') +
75
+ '\n' +
76
+ chalk.yellow(oneTimeKey) +
77
+ '\n\n' +
78
+ chalk.yellow('⚠ The link and key should be sent via different channels for security.')
79
+ );
80
+ } catch (err: any) {
81
+ s.stop();
82
+ error(err.message);
83
+ }
84
+ });
@@ -0,0 +1,91 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import {
4
+ isLoggedIn,
5
+ requireAuth,
6
+ loadProjectConfig,
7
+ } from '../lib/config.js';
8
+ import { getVaultEntries } from '../lib/api.js';
9
+ import { readEnvFile } from '../lib/env-file.js';
10
+ import { error, info, spinner } from '../lib/ui.js';
11
+
12
+ export const statusCommand = new Command('status')
13
+ .description('Show current project status')
14
+ .action(async () => {
15
+ const projectConfig = loadProjectConfig();
16
+
17
+ if (!projectConfig) {
18
+ info('Not in a Keyra project. Run `keyra init` first.');
19
+ return;
20
+ }
21
+
22
+ console.log();
23
+ console.log(chalk.bold(' Keyra Status'));
24
+ console.log(chalk.dim(` ${'─'.repeat(40)}`));
25
+ console.log(` ${chalk.dim('Project:')} ${chalk.green(projectConfig.projectName)}`);
26
+ console.log(` ${chalk.dim('ID:')} ${chalk.dim(projectConfig.projectId)}`);
27
+
28
+ if (!isLoggedIn()) {
29
+ console.log();
30
+ info('Not logged in. Run `keyra login` to sync.');
31
+ return;
32
+ }
33
+
34
+ let config;
35
+ try {
36
+ config = requireAuth();
37
+ } catch (err: any) {
38
+ error(err.message);
39
+ return;
40
+ }
41
+
42
+ const s = spinner('Checking vault...');
43
+ s.start();
44
+
45
+ try {
46
+ const entries = await getVaultEntries(projectConfig.projectId);
47
+ s.stop();
48
+
49
+ console.log(` ${chalk.dim('Vault:')} ${entries.length} variable${entries.length !== 1 ? 's' : ''}`);
50
+
51
+ // Compare with local .env
52
+ const envData = readEnvFile();
53
+ if (envData) {
54
+ const localKeys = new Set(Object.keys(envData.vars));
55
+ const vaultKeys = new Set(entries.map((e) => e.key_name));
56
+
57
+ const onlyLocal = [...localKeys].filter((k) => !vaultKeys.has(k));
58
+ const onlyVault = [...vaultKeys].filter((k) => !localKeys.has(k));
59
+ const inBoth = [...localKeys].filter((k) => vaultKeys.has(k));
60
+
61
+ console.log(` ${chalk.dim('Local:')} ${localKeys.size} variable${localKeys.size !== 1 ? 's' : ''} (${envData.filename})`);
62
+ console.log();
63
+
64
+ if (onlyLocal.length === 0 && onlyVault.length === 0) {
65
+ console.log(chalk.green(' ✓ Local and vault keys are in sync'));
66
+ } else {
67
+ if (onlyLocal.length > 0) {
68
+ console.log(
69
+ chalk.yellow(
70
+ ` ⚠ ${onlyLocal.length} key${onlyLocal.length !== 1 ? 's' : ''} only in local: ${onlyLocal.join(', ')}`
71
+ )
72
+ );
73
+ }
74
+ if (onlyVault.length > 0) {
75
+ console.log(
76
+ chalk.yellow(
77
+ ` ⚠ ${onlyVault.length} key${onlyVault.length !== 1 ? 's' : ''} only in vault: ${onlyVault.join(', ')}`
78
+ )
79
+ );
80
+ }
81
+ }
82
+ } else {
83
+ console.log(` ${chalk.dim('Local:')} ${chalk.yellow('no .env file found')}`);
84
+ }
85
+
86
+ console.log();
87
+ } catch (err: any) {
88
+ s.stop();
89
+ error(err.message);
90
+ }
91
+ });
@@ -0,0 +1,101 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { requireAuth, requireProjectConfig } from '../lib/config.js';
4
+ import { getVaultEntries } from '../lib/api.js';
5
+ import { readEnvFile } from '../lib/env-file.js';
6
+ import { error, warning, spinner, printTable } from '../lib/ui.js';
7
+
8
+ export const validateCommand = new Command('validate')
9
+ .description('Check local .env has all required vault variables')
10
+ .action(async () => {
11
+ let projectConfig;
12
+ try {
13
+ projectConfig = requireProjectConfig();
14
+ } catch (err: any) {
15
+ error(err.message);
16
+ process.exit(1);
17
+ return;
18
+ }
19
+
20
+ // Need auth to fetch vault key names
21
+ let config;
22
+ try {
23
+ config = requireAuth();
24
+ } catch (err: any) {
25
+ error(err.message);
26
+ process.exit(1);
27
+ return;
28
+ }
29
+
30
+ const s = spinner('Checking vault...');
31
+ s.start();
32
+
33
+ let entries;
34
+ try {
35
+ entries = await getVaultEntries(projectConfig.projectId);
36
+ s.stop();
37
+ } catch (err: any) {
38
+ s.stop();
39
+ error(err.message);
40
+ process.exit(1);
41
+ return;
42
+ }
43
+
44
+ if (entries.length === 0) {
45
+ warning('No variables in vault to validate against.');
46
+ return;
47
+ }
48
+
49
+ const envData = readEnvFile();
50
+ const localVars = envData?.vars ?? {};
51
+
52
+ const rows: { key: string; value: string; status: string }[] = [];
53
+ let passed = 0;
54
+ let issues = 0;
55
+
56
+ for (const entry of entries) {
57
+ const localValue = localVars[entry.key_name];
58
+ if (localValue === undefined) {
59
+ rows.push({
60
+ key: entry.key_name,
61
+ value: 'MISSING',
62
+ status: 'missing',
63
+ });
64
+ issues++;
65
+ } else if (localValue.trim() === '') {
66
+ rows.push({
67
+ key: entry.key_name,
68
+ value: 'EMPTY',
69
+ status: 'empty',
70
+ });
71
+ issues++;
72
+ } else {
73
+ rows.push({
74
+ key: entry.key_name,
75
+ value: 'present',
76
+ status: 'present',
77
+ });
78
+ passed++;
79
+ }
80
+ }
81
+
82
+ console.log();
83
+ printTable(rows);
84
+ console.log();
85
+
86
+ const total = entries.length;
87
+ if (issues === 0) {
88
+ console.log(
89
+ chalk.green.bold(` ${passed} of ${total} checks passed. All good!`)
90
+ );
91
+ } else {
92
+ console.log(
93
+ chalk.yellow(
94
+ ` ${passed} of ${total} checks passed. ${issues} issue${issues !== 1 ? 's' : ''} found.`
95
+ )
96
+ );
97
+ }
98
+
99
+ console.log();
100
+ process.exit(issues > 0 ? 1 : 0);
101
+ });
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import chalk from 'chalk';
4
+ import { loginCommand } from './commands/login.js';
5
+ import { logoutCommand } from './commands/logout.js';
6
+ import { initCommand } from './commands/init.js';
7
+ import { pushCommand } from './commands/push.js';
8
+ import { pullCommand } from './commands/pull.js';
9
+ import { validateCommand } from './commands/validate.js';
10
+ import { shareCommand } from './commands/share.js';
11
+ import { listCommand } from './commands/list.js';
12
+ import { statusCommand } from './commands/status.js';
13
+ import { guardCommand } from './commands/guard.js';
14
+ import { scanCommand } from './commands/scan.js';
15
+
16
+ const program = new Command();
17
+
18
+ program
19
+ .name('keyra')
20
+ .description('Encrypted .env vault. Sync, share, never lose an API key.')
21
+ .version('0.1.0');
22
+
23
+ program.addCommand(loginCommand);
24
+ program.addCommand(logoutCommand);
25
+ program.addCommand(initCommand);
26
+ program.addCommand(pushCommand);
27
+ program.addCommand(pullCommand);
28
+ program.addCommand(validateCommand);
29
+ program.addCommand(shareCommand);
30
+ program.addCommand(listCommand);
31
+ program.addCommand(statusCommand);
32
+ program.addCommand(guardCommand);
33
+ program.addCommand(scanCommand);
34
+
35
+ program.parseAsync(process.argv).catch((err) => {
36
+ console.error(chalk.red('Error:'), err.message);
37
+ process.exit(1);
38
+ });
@@ -0,0 +1,136 @@
1
+ import { loadConfig } from './config.js';
2
+ import type { Project, VaultEntry, EncryptedEntry, ShareData } from '../types.js';
3
+
4
+ function getBaseUrl(): string {
5
+ const config = loadConfig();
6
+ return config.apiUrl || 'http://localhost:3000';
7
+ }
8
+
9
+ function getHeaders(): Record<string, string> {
10
+ const config = loadConfig();
11
+ const headers: Record<string, string> = {
12
+ 'Content-Type': 'application/json',
13
+ };
14
+ if (config.accessToken) {
15
+ headers['Authorization'] = `Bearer ${config.accessToken}`;
16
+ }
17
+ return headers;
18
+ }
19
+
20
+ async function handleResponse(res: Response): Promise<any> {
21
+ if (res.status === 401) {
22
+ throw new Error('Session expired. Run `keyra login` to re-authenticate.');
23
+ }
24
+ if (res.status === 403) {
25
+ const data = await res.json().catch(() => ({}));
26
+ const msg: string = data.error || '';
27
+ if (msg.toLowerCase().includes('project')) {
28
+ throw new Error(
29
+ 'Project limit reached for your plan.\n Upgrade at keyra.dev/dashboard/settings for more projects.'
30
+ );
31
+ }
32
+ if (msg.toLowerCase().includes('variable') || msg.toLowerCase().includes('vars')) {
33
+ throw new Error(
34
+ 'Variable limit reached for this project.\n Upgrade at keyra.dev/dashboard/settings for more variables.'
35
+ );
36
+ }
37
+ if (msg.toLowerCase().includes('sharing') || msg.toLowerCase().includes('share')) {
38
+ throw new Error(
39
+ 'Sharing requires a Pro plan.\n Upgrade at keyra.dev/dashboard/settings to enable sharing.'
40
+ );
41
+ }
42
+ if (data.upgrade) {
43
+ throw new Error(
44
+ `${msg || 'Plan limit reached.'}\n Upgrade at keyra.dev/dashboard/settings`
45
+ );
46
+ }
47
+ throw new Error(msg || 'Access denied.');
48
+ }
49
+ if (!res.ok) {
50
+ const data = await res.json().catch(() => ({}));
51
+ throw new Error(data.error || `Request failed with status ${res.status}`);
52
+ }
53
+ return res.json();
54
+ }
55
+
56
+ export async function apiGet(path: string): Promise<any> {
57
+ const res = await fetch(`${getBaseUrl()}${path}`, {
58
+ method: 'GET',
59
+ headers: getHeaders(),
60
+ }).catch(() => {
61
+ throw new Error("Can't reach server. Check your connection.");
62
+ });
63
+ return handleResponse(res);
64
+ }
65
+
66
+ export async function apiPost(path: string, body: any): Promise<any> {
67
+ const res = await fetch(`${getBaseUrl()}${path}`, {
68
+ method: 'POST',
69
+ headers: getHeaders(),
70
+ body: JSON.stringify(body),
71
+ }).catch(() => {
72
+ throw new Error("Can't reach server. Check your connection.");
73
+ });
74
+ return handleResponse(res);
75
+ }
76
+
77
+ export async function apiPatch(path: string, body: any): Promise<any> {
78
+ const res = await fetch(`${getBaseUrl()}${path}`, {
79
+ method: 'PATCH',
80
+ headers: getHeaders(),
81
+ body: JSON.stringify(body),
82
+ }).catch(() => {
83
+ throw new Error("Can't reach server. Check your connection.");
84
+ });
85
+ return handleResponse(res);
86
+ }
87
+
88
+ export async function apiDelete(path: string): Promise<any> {
89
+ const res = await fetch(`${getBaseUrl()}${path}`, {
90
+ method: 'DELETE',
91
+ headers: getHeaders(),
92
+ }).catch(() => {
93
+ throw new Error("Can't reach server. Check your connection.");
94
+ });
95
+ return handleResponse(res);
96
+ }
97
+
98
+ // Specific endpoints
99
+
100
+ export async function createDeviceCode(): Promise<{ deviceCode: string }> {
101
+ return apiPost('/api/cli/auth', {});
102
+ }
103
+
104
+ export async function pollAuth(
105
+ deviceCode: string
106
+ ): Promise<{ status: string; accessToken?: string }> {
107
+ return apiGet(`/api/cli/auth?code=${deviceCode}`);
108
+ }
109
+
110
+ export async function getProjects(): Promise<Project[]> {
111
+ return apiGet('/api/projects');
112
+ }
113
+
114
+ export async function createProject(
115
+ name: string,
116
+ description?: string
117
+ ): Promise<Project> {
118
+ return apiPost('/api/projects', { name, description });
119
+ }
120
+
121
+ export async function getVaultEntries(projectId: string): Promise<VaultEntry[]> {
122
+ return apiGet(`/api/vault?project_id=${projectId}`);
123
+ }
124
+
125
+ export async function upsertVaultEntries(
126
+ projectId: string,
127
+ entries: EncryptedEntry[]
128
+ ): Promise<void> {
129
+ await apiPost('/api/vault/sync', { projectId, entries });
130
+ }
131
+
132
+ export async function createShareLink(
133
+ data: ShareData
134
+ ): Promise<{ url: string; token: string }> {
135
+ return apiPost('/api/share', data);
136
+ }
@@ -0,0 +1,94 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import type { Config, ProjectConfig } from '../types.js';
5
+
6
+ const CONFIG_DIR = path.join(os.homedir(), '.keyra');
7
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
8
+ const PROJECT_CONFIG_FILE = '.keyra.json';
9
+
10
+ function ensureConfigDir(): void {
11
+ if (!fs.existsSync(CONFIG_DIR)) {
12
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
13
+ }
14
+ }
15
+
16
+ export function loadConfig(): Partial<Config> {
17
+ ensureConfigDir();
18
+ if (!fs.existsSync(CONFIG_FILE)) return {};
19
+ try {
20
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
21
+ } catch {
22
+ return {};
23
+ }
24
+ }
25
+
26
+ export function saveConfig(partial: Partial<Config>): void {
27
+ ensureConfigDir();
28
+ const existing = loadConfig();
29
+ const merged = { ...existing, ...partial };
30
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2));
31
+ }
32
+
33
+ export function clearConfig(): void {
34
+ ensureConfigDir();
35
+ if (fs.existsSync(CONFIG_FILE)) {
36
+ fs.unlinkSync(CONFIG_FILE);
37
+ }
38
+ }
39
+
40
+ export function isLoggedIn(): boolean {
41
+ const config = loadConfig();
42
+ return !!(config.accessToken && config.passphrase);
43
+ }
44
+
45
+ export function requireAuth(): Config {
46
+ const config = loadConfig();
47
+ if (!config.accessToken || !config.passphrase) {
48
+ throw new Error('Not logged in. Run `keyra login` first.');
49
+ }
50
+ return {
51
+ apiUrl: config.apiUrl || 'http://localhost:3000',
52
+ accessToken: config.accessToken,
53
+ passphrase: config.passphrase,
54
+ };
55
+ }
56
+
57
+ export function loadProjectConfig(): ProjectConfig | null {
58
+ const filePath = path.join(process.cwd(), PROJECT_CONFIG_FILE);
59
+ if (!fs.existsSync(filePath)) return null;
60
+ try {
61
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ export function saveProjectConfig(data: ProjectConfig): void {
68
+ const filePath = path.join(process.cwd(), PROJECT_CONFIG_FILE);
69
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
70
+ }
71
+
72
+ export function hasProjectConfig(): boolean {
73
+ return fs.existsSync(path.join(process.cwd(), PROJECT_CONFIG_FILE));
74
+ }
75
+
76
+ export function requireProjectConfig(): ProjectConfig {
77
+ const config = loadProjectConfig();
78
+ if (!config) {
79
+ throw new Error('Not in a Keyra project. Run `keyra init` first.');
80
+ }
81
+ return config;
82
+ }
83
+
84
+ export function addToGitignore(entry: string): void {
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, `\n${entry}\n`);
90
+ }
91
+ } else {
92
+ fs.writeFileSync(gitignorePath, `${entry}\n`);
93
+ }
94
+ }