cli4ai 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +275 -0
- package/package.json +49 -0
- package/src/bin.ts +120 -0
- package/src/cli.ts +256 -0
- package/src/commands/add.ts +530 -0
- package/src/commands/browse.ts +449 -0
- package/src/commands/config.ts +126 -0
- package/src/commands/info.ts +102 -0
- package/src/commands/init.test.ts +163 -0
- package/src/commands/init.ts +560 -0
- package/src/commands/list.ts +89 -0
- package/src/commands/mcp-config.ts +59 -0
- package/src/commands/remove.ts +72 -0
- package/src/commands/routines.ts +393 -0
- package/src/commands/run.ts +45 -0
- package/src/commands/search.ts +148 -0
- package/src/commands/secrets.ts +273 -0
- package/src/commands/start.ts +40 -0
- package/src/commands/update.ts +218 -0
- package/src/core/config.test.ts +188 -0
- package/src/core/config.ts +649 -0
- package/src/core/execute.ts +507 -0
- package/src/core/link.test.ts +238 -0
- package/src/core/link.ts +190 -0
- package/src/core/lockfile.test.ts +337 -0
- package/src/core/lockfile.ts +308 -0
- package/src/core/manifest.test.ts +327 -0
- package/src/core/manifest.ts +319 -0
- package/src/core/routine-engine.test.ts +139 -0
- package/src/core/routine-engine.ts +725 -0
- package/src/core/routines.ts +111 -0
- package/src/core/secrets.test.ts +79 -0
- package/src/core/secrets.ts +430 -0
- package/src/lib/cli.ts +234 -0
- package/src/mcp/adapter.test.ts +132 -0
- package/src/mcp/adapter.ts +123 -0
- package/src/mcp/config-gen.test.ts +214 -0
- package/src/mcp/config-gen.ts +106 -0
- package/src/mcp/server.ts +363 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routine discovery and resolution.
|
|
3
|
+
*
|
|
4
|
+
* Routines live in:
|
|
5
|
+
* - local: <project>/.cli4ai/routines
|
|
6
|
+
* - global: ~/.cli4ai/routines
|
|
7
|
+
*
|
|
8
|
+
* Resolution order:
|
|
9
|
+
* - local before global (unless globalOnly)
|
|
10
|
+
* - within a scope: .routine.json before .routine.sh
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, readdirSync, statSync } from 'fs';
|
|
14
|
+
import { resolve } from 'path';
|
|
15
|
+
import { ensureCli4aiHome, ensureLocalDir, ROUTINES_DIR, LOCAL_ROUTINES_DIR } from './config.js';
|
|
16
|
+
|
|
17
|
+
export type RoutineKind = 'json' | 'bash';
|
|
18
|
+
export type RoutineScope = 'local' | 'global';
|
|
19
|
+
|
|
20
|
+
export interface RoutineInfo {
|
|
21
|
+
name: string;
|
|
22
|
+
kind: RoutineKind;
|
|
23
|
+
scope: RoutineScope;
|
|
24
|
+
path: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ResolveRoutineOptions {
|
|
28
|
+
globalOnly?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ROUTINE_FILES = [
|
|
32
|
+
{ kind: 'json' as const, suffix: '.routine.json' },
|
|
33
|
+
{ kind: 'bash' as const, suffix: '.routine.sh' }
|
|
34
|
+
] as const;
|
|
35
|
+
|
|
36
|
+
const NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
37
|
+
|
|
38
|
+
export function validateRoutineName(name: string): void {
|
|
39
|
+
if (!NAME_PATTERN.test(name)) {
|
|
40
|
+
throw new Error(`Invalid routine name: ${name}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function listRoutinesInDir(dir: string, scope: RoutineScope): RoutineInfo[] {
|
|
45
|
+
if (!existsSync(dir)) return [];
|
|
46
|
+
|
|
47
|
+
const results: RoutineInfo[] = [];
|
|
48
|
+
|
|
49
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
50
|
+
if (!entry.isFile()) continue;
|
|
51
|
+
|
|
52
|
+
for (const def of ROUTINE_FILES) {
|
|
53
|
+
if (!entry.name.endsWith(def.suffix)) continue;
|
|
54
|
+
|
|
55
|
+
const name = entry.name.slice(0, -def.suffix.length);
|
|
56
|
+
if (!NAME_PATTERN.test(name)) continue;
|
|
57
|
+
|
|
58
|
+
const fullPath = resolve(dir, entry.name);
|
|
59
|
+
|
|
60
|
+
// Best-effort: ensure it's a regular file
|
|
61
|
+
try {
|
|
62
|
+
if (!statSync(fullPath).isFile()) continue;
|
|
63
|
+
} catch {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
results.push({
|
|
68
|
+
name,
|
|
69
|
+
kind: def.kind,
|
|
70
|
+
scope,
|
|
71
|
+
path: fullPath
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return results;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function getLocalRoutines(projectDir: string): RoutineInfo[] {
|
|
80
|
+
ensureLocalDir(projectDir);
|
|
81
|
+
const dir = resolve(projectDir, LOCAL_ROUTINES_DIR);
|
|
82
|
+
return listRoutinesInDir(dir, 'local');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function getGlobalRoutines(): RoutineInfo[] {
|
|
86
|
+
ensureCli4aiHome();
|
|
87
|
+
return listRoutinesInDir(ROUTINES_DIR, 'global');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function resolveRoutine(name: string, projectDir: string, options: ResolveRoutineOptions = {}): RoutineInfo | null {
|
|
91
|
+
validateRoutineName(name);
|
|
92
|
+
|
|
93
|
+
const candidates: Array<{ scope: RoutineScope; baseDir: string }> = options.globalOnly
|
|
94
|
+
? [{ scope: 'global', baseDir: ROUTINES_DIR }]
|
|
95
|
+
: [
|
|
96
|
+
{ scope: 'local', baseDir: resolve(projectDir, LOCAL_ROUTINES_DIR) },
|
|
97
|
+
{ scope: 'global', baseDir: ROUTINES_DIR }
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
for (const { scope, baseDir } of candidates) {
|
|
101
|
+
for (const def of ROUTINE_FILES) {
|
|
102
|
+
const routinePath = resolve(baseDir, `${name}${def.suffix}`);
|
|
103
|
+
if (existsSync(routinePath)) {
|
|
104
|
+
return { name, kind: def.kind, scope, path: routinePath };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for secrets.ts
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
6
|
+
import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from 'fs';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
import { tmpdir, hostname, userInfo } from 'os';
|
|
9
|
+
import { createCipheriv, createHash, randomBytes } from 'crypto';
|
|
10
|
+
import { getSecret, listSecretKeys } from './secrets.js';
|
|
11
|
+
|
|
12
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
13
|
+
|
|
14
|
+
function legacyEncrypt(value: string): string {
|
|
15
|
+
const machineId = `${hostname()}-${userInfo().username}-cli4ai-secrets`;
|
|
16
|
+
const key = createHash('sha256').update(machineId).digest();
|
|
17
|
+
const iv = randomBytes(16);
|
|
18
|
+
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
19
|
+
|
|
20
|
+
let encrypted = cipher.update(value, 'utf8', 'hex');
|
|
21
|
+
encrypted += cipher.final('hex');
|
|
22
|
+
|
|
23
|
+
const authTag = cipher.getAuthTag();
|
|
24
|
+
|
|
25
|
+
return JSON.stringify({
|
|
26
|
+
iv: iv.toString('hex'),
|
|
27
|
+
encrypted,
|
|
28
|
+
authTag: authTag.toString('hex')
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('secrets', () => {
|
|
33
|
+
let tempDir: string;
|
|
34
|
+
let secretsFile: string;
|
|
35
|
+
let saltFile: string;
|
|
36
|
+
let originalSecretsFileEnv: string | undefined;
|
|
37
|
+
let originalSaltFileEnv: string | undefined;
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
tempDir = mkdtempSync(join(tmpdir(), 'cli4ai-secrets-test-'));
|
|
41
|
+
secretsFile = join(tempDir, 'secrets.json');
|
|
42
|
+
saltFile = join(tempDir, 'secrets.salt');
|
|
43
|
+
|
|
44
|
+
originalSecretsFileEnv = process.env.C4AI_SECRETS_FILE;
|
|
45
|
+
originalSaltFileEnv = process.env.C4AI_SECRETS_SALT_FILE;
|
|
46
|
+
|
|
47
|
+
process.env.C4AI_SECRETS_FILE = secretsFile;
|
|
48
|
+
process.env.C4AI_SECRETS_SALT_FILE = saltFile;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
if (originalSecretsFileEnv !== undefined) process.env.C4AI_SECRETS_FILE = originalSecretsFileEnv;
|
|
53
|
+
else delete process.env.C4AI_SECRETS_FILE;
|
|
54
|
+
|
|
55
|
+
if (originalSaltFileEnv !== undefined) process.env.C4AI_SECRETS_SALT_FILE = originalSaltFileEnv;
|
|
56
|
+
else delete process.env.C4AI_SECRETS_SALT_FILE;
|
|
57
|
+
|
|
58
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('decrypts legacy secrets and migrates to salted format', () => {
|
|
62
|
+
writeFileSync(
|
|
63
|
+
secretsFile,
|
|
64
|
+
JSON.stringify({ SLACK_BOT_TOKEN: legacyEncrypt('xoxb-test') }, null, 2),
|
|
65
|
+
{ mode: 0o600 }
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
expect(getSecret('SLACK_BOT_TOKEN')).toBe('xoxb-test');
|
|
69
|
+
expect(listSecretKeys()).toEqual(['SLACK_BOT_TOKEN']);
|
|
70
|
+
|
|
71
|
+
// Migration should create a salt file for the new scheme.
|
|
72
|
+
expect(existsSync(saltFile)).toBe(true);
|
|
73
|
+
expect(readFileSync(saltFile).length).toBe(32);
|
|
74
|
+
|
|
75
|
+
// Subsequent reads should still work.
|
|
76
|
+
expect(getSecret('SLACK_BOT_TOKEN')).toBe('xoxb-test');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secrets management for cli4ai
|
|
3
|
+
*
|
|
4
|
+
* Priority order:
|
|
5
|
+
* 1. Scoped environment variables (C4AI_<PKG>__<KEY>)
|
|
6
|
+
* 2. Environment variables (for CI/CD)
|
|
7
|
+
* 3. Scoped local secrets vault (<pkg>:<key>)
|
|
8
|
+
* 4. Global local secrets vault (~/.cli4ai/secrets.json)
|
|
9
|
+
*
|
|
10
|
+
* Secrets are stored encrypted using a machine-specific key with random entropy.
|
|
11
|
+
*
|
|
12
|
+
* SECURITY: The encryption key is derived from:
|
|
13
|
+
* - Machine hostname
|
|
14
|
+
* - Username
|
|
15
|
+
* - A random 32-byte salt stored in ~/.cli4ai/secrets.salt
|
|
16
|
+
*
|
|
17
|
+
* This ensures that even if an attacker knows the hostname and username,
|
|
18
|
+
* they cannot reconstruct the key without access to the salt file.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFileSync, writeFileSync, existsSync, chmodSync, statSync, mkdirSync } from 'fs';
|
|
22
|
+
import { createCipheriv, createDecipheriv, randomBytes, createHash, pbkdf2Sync } from 'crypto';
|
|
23
|
+
import { hostname, userInfo, platform } from 'os';
|
|
24
|
+
import { dirname, resolve } from 'path';
|
|
25
|
+
import { CLI4AI_HOME, ensureCli4aiHome } from './config.js';
|
|
26
|
+
|
|
27
|
+
const SECRETS_FILE = resolve(CLI4AI_HOME, 'secrets.json');
|
|
28
|
+
const SALT_FILE = resolve(CLI4AI_HOME, 'secrets.salt');
|
|
29
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
30
|
+
const PBKDF2_ITERATIONS = 100000; // Strong iteration count for key derivation
|
|
31
|
+
|
|
32
|
+
const SECRETS_FILE_OVERRIDE_ENV = 'C4AI_SECRETS_FILE';
|
|
33
|
+
const SALT_FILE_OVERRIDE_ENV = 'C4AI_SECRETS_SALT_FILE';
|
|
34
|
+
|
|
35
|
+
export type SecretSource = 'env_scoped' | 'env' | 'vault_scoped' | 'vault' | 'missing';
|
|
36
|
+
|
|
37
|
+
function getSecretsFilePath(): string {
|
|
38
|
+
const override = process.env[SECRETS_FILE_OVERRIDE_ENV];
|
|
39
|
+
return override ? resolve(override) : SECRETS_FILE;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getSaltFilePath(): string {
|
|
43
|
+
const override = process.env[SALT_FILE_OVERRIDE_ENV];
|
|
44
|
+
return override ? resolve(override) : SALT_FILE;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function ensureSecretsDir(filePath: string): void {
|
|
48
|
+
const dir = dirname(filePath);
|
|
49
|
+
if (!existsSync(dir)) {
|
|
50
|
+
mkdirSync(dir, { recursive: true });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizeEnvVarSegment(value: string): string {
|
|
55
|
+
return value
|
|
56
|
+
.trim()
|
|
57
|
+
.toUpperCase()
|
|
58
|
+
.replace(/[^A-Z0-9]+/g, '_')
|
|
59
|
+
.replace(/^_+|_+$/g, '');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getScopedEnvVarName(packageName: string, key: string): string {
|
|
63
|
+
return `C4AI_${normalizeEnvVarSegment(packageName)}__${normalizeEnvVarSegment(key)}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function makeScopedVaultKey(packageName: string, key: string): string {
|
|
67
|
+
return `${packageName}:${key}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getLegacyMachineKey(): Buffer {
|
|
71
|
+
const machineId = `${hostname()}-${userInfo().username}-cli4ai-secrets`;
|
|
72
|
+
return createHash('sha256').update(machineId).digest();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get or create the random salt for key derivation.
|
|
77
|
+
* The salt is stored in a separate file with restricted permissions.
|
|
78
|
+
*/
|
|
79
|
+
function getSaltIfExists(): Buffer | null {
|
|
80
|
+
const saltFilePath = getSaltFilePath();
|
|
81
|
+
|
|
82
|
+
if (existsSync(saltFilePath)) {
|
|
83
|
+
// SECURITY: Verify file permissions on Unix-like systems
|
|
84
|
+
if (platform() !== 'win32') {
|
|
85
|
+
try {
|
|
86
|
+
const stats = statSync(saltFilePath);
|
|
87
|
+
const mode = stats.mode & 0o777;
|
|
88
|
+
if (mode !== 0o600) {
|
|
89
|
+
console.error(`Warning: Salt file has insecure permissions (${mode.toString(8)}). Should be 600.`);
|
|
90
|
+
// Attempt to fix permissions
|
|
91
|
+
chmodSync(saltFilePath, 0o600);
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
// Ignore permission check errors
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const salt = readFileSync(saltFilePath);
|
|
100
|
+
if (salt.length === 32) {
|
|
101
|
+
return salt;
|
|
102
|
+
}
|
|
103
|
+
// Invalid salt file, regenerate
|
|
104
|
+
console.error('Warning: Invalid salt file, regenerating...');
|
|
105
|
+
} catch {
|
|
106
|
+
// Failed to read, regenerate
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function getOrCreateSalt(): Buffer {
|
|
114
|
+
const saltFilePath = getSaltFilePath();
|
|
115
|
+
ensureSecretsDir(saltFilePath);
|
|
116
|
+
|
|
117
|
+
const salt = getSaltIfExists();
|
|
118
|
+
if (salt) return salt;
|
|
119
|
+
|
|
120
|
+
// Generate new random salt
|
|
121
|
+
const newSalt = randomBytes(32);
|
|
122
|
+
writeFileSync(saltFilePath, newSalt, { mode: 0o600 });
|
|
123
|
+
return newSalt;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Try to get additional machine-specific entropy.
|
|
128
|
+
* This is best-effort and won't fail if sources are unavailable.
|
|
129
|
+
*/
|
|
130
|
+
function getMachineEntropy(): string {
|
|
131
|
+
const parts: string[] = [
|
|
132
|
+
hostname(),
|
|
133
|
+
userInfo().username,
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
// Try to get machine-id on Linux
|
|
137
|
+
if (platform() === 'linux') {
|
|
138
|
+
try {
|
|
139
|
+
const machineId = readFileSync('/etc/machine-id', 'utf-8').trim();
|
|
140
|
+
parts.push(machineId);
|
|
141
|
+
} catch {
|
|
142
|
+
// Not available
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Try to get hardware UUID on macOS
|
|
147
|
+
if (platform() === 'darwin') {
|
|
148
|
+
try {
|
|
149
|
+
// This is a backup - the salt file is the primary entropy source
|
|
150
|
+
const { execSync } = require('child_process');
|
|
151
|
+
const uuid = execSync('ioreg -rd1 -c IOPlatformExpertDevice | grep IOPlatformUUID', {
|
|
152
|
+
encoding: 'utf-8',
|
|
153
|
+
timeout: 1000,
|
|
154
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
155
|
+
});
|
|
156
|
+
const match = uuid.match(/"([^"]+)"$/);
|
|
157
|
+
if (match) {
|
|
158
|
+
parts.push(match[1]);
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
// Not available
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return parts.join('-');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Generate a machine-specific encryption key with strong entropy.
|
|
170
|
+
*
|
|
171
|
+
* Uses PBKDF2 with:
|
|
172
|
+
* - Machine-specific data (hostname, username, hardware IDs when available)
|
|
173
|
+
* - A random 32-byte salt stored on disk
|
|
174
|
+
* - 100,000 iterations for key stretching
|
|
175
|
+
*/
|
|
176
|
+
function getMachineKey(options: { createSalt: boolean }): Buffer {
|
|
177
|
+
const salt = options.createSalt ? getOrCreateSalt() : getSaltIfExists();
|
|
178
|
+
if (!salt) {
|
|
179
|
+
throw new Error('Secrets salt file is missing');
|
|
180
|
+
}
|
|
181
|
+
const machineData = getMachineEntropy();
|
|
182
|
+
|
|
183
|
+
// Use PBKDF2 for proper key derivation with the random salt
|
|
184
|
+
return pbkdf2Sync(machineData, salt, PBKDF2_ITERATIONS, 32, 'sha512');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Encrypt a string value
|
|
189
|
+
*/
|
|
190
|
+
function encrypt(text: string): string {
|
|
191
|
+
const key = getMachineKey({ createSalt: true });
|
|
192
|
+
const iv = randomBytes(16);
|
|
193
|
+
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
194
|
+
|
|
195
|
+
let encrypted = cipher.update(text, 'utf8', 'hex');
|
|
196
|
+
encrypted += cipher.final('hex');
|
|
197
|
+
|
|
198
|
+
const authTag = cipher.getAuthTag();
|
|
199
|
+
|
|
200
|
+
return JSON.stringify({
|
|
201
|
+
iv: iv.toString('hex'),
|
|
202
|
+
encrypted,
|
|
203
|
+
authTag: authTag.toString('hex')
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function decryptWithKey(data: string, key: Buffer): string {
|
|
208
|
+
const { iv, encrypted, authTag } = JSON.parse(data);
|
|
209
|
+
|
|
210
|
+
const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(iv, 'hex'));
|
|
211
|
+
decipher.setAuthTag(Buffer.from(authTag, 'hex'));
|
|
212
|
+
|
|
213
|
+
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
|
214
|
+
decrypted += decipher.final('utf8');
|
|
215
|
+
|
|
216
|
+
return decrypted;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Decrypt a string value
|
|
221
|
+
*/
|
|
222
|
+
function decrypt(data: string): string {
|
|
223
|
+
const key = getMachineKey({ createSalt: false });
|
|
224
|
+
return decryptWithKey(data, key);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function decryptLegacy(data: string): string {
|
|
228
|
+
const key = getLegacyMachineKey();
|
|
229
|
+
return decryptWithKey(data, key);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Check and warn about insecure file permissions
|
|
234
|
+
*/
|
|
235
|
+
function checkFilePermissions(filePath: string, fileName: string): void {
|
|
236
|
+
if (platform() === 'win32') return;
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
const stats = statSync(filePath);
|
|
240
|
+
const mode = stats.mode & 0o777;
|
|
241
|
+
// Warn if file is readable by group or others
|
|
242
|
+
if (mode & 0o077) {
|
|
243
|
+
console.error(`Warning: ${fileName} has insecure permissions (${mode.toString(8)}). Should be 600.`);
|
|
244
|
+
// Attempt to fix permissions
|
|
245
|
+
try {
|
|
246
|
+
chmodSync(filePath, 0o600);
|
|
247
|
+
console.error(` Fixed permissions to 600.`);
|
|
248
|
+
} catch {
|
|
249
|
+
console.error(` Could not fix permissions. Please run: chmod 600 ${filePath}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
} catch {
|
|
253
|
+
// Ignore errors
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Load all secrets from vault
|
|
259
|
+
*/
|
|
260
|
+
function loadSecrets(): Record<string, string> {
|
|
261
|
+
const secretsFilePath = getSecretsFilePath();
|
|
262
|
+
ensureSecretsDir(secretsFilePath);
|
|
263
|
+
|
|
264
|
+
if (!existsSync(secretsFilePath)) {
|
|
265
|
+
return {};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// SECURITY: Check file permissions
|
|
269
|
+
checkFilePermissions(secretsFilePath, 'secrets.json');
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
const content = readFileSync(secretsFilePath, 'utf-8');
|
|
273
|
+
const encrypted = JSON.parse(content) as Record<string, unknown>;
|
|
274
|
+
const secrets: Record<string, string> = {};
|
|
275
|
+
const migrated: Record<string, string> = {};
|
|
276
|
+
|
|
277
|
+
for (const [key, value] of Object.entries(encrypted)) {
|
|
278
|
+
if (typeof value !== 'string') continue;
|
|
279
|
+
try {
|
|
280
|
+
secrets[key] = decrypt(value);
|
|
281
|
+
} catch {
|
|
282
|
+
// Back-compat: attempt legacy decrypt (pre-salt secrets).
|
|
283
|
+
try {
|
|
284
|
+
const legacy = decryptLegacy(value);
|
|
285
|
+
secrets[key] = legacy;
|
|
286
|
+
migrated[key] = legacy;
|
|
287
|
+
} catch {
|
|
288
|
+
// Skip corrupted/unreadable entries
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// If we successfully decrypted legacy secrets, rewrite those entries using the current scheme.
|
|
294
|
+
if (Object.keys(migrated).length > 0) {
|
|
295
|
+
try {
|
|
296
|
+
const updated: Record<string, unknown> = { ...encrypted };
|
|
297
|
+
for (const [k, v] of Object.entries(migrated)) {
|
|
298
|
+
updated[k] = encrypt(v);
|
|
299
|
+
}
|
|
300
|
+
writeFileSync(secretsFilePath, JSON.stringify(updated, null, 2), { mode: 0o600 });
|
|
301
|
+
} catch {
|
|
302
|
+
// Best-effort migration only. If we can't write (e.g. restricted FS), still return decrypted secrets.
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return secrets;
|
|
307
|
+
} catch {
|
|
308
|
+
return {};
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Save all secrets to vault
|
|
314
|
+
*/
|
|
315
|
+
function saveSecrets(secrets: Record<string, string>): void {
|
|
316
|
+
const secretsFilePath = getSecretsFilePath();
|
|
317
|
+
ensureSecretsDir(secretsFilePath);
|
|
318
|
+
|
|
319
|
+
const encrypted: Record<string, string> = {};
|
|
320
|
+
|
|
321
|
+
for (const [key, value] of Object.entries(secrets)) {
|
|
322
|
+
encrypted[key] = encrypt(value);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
writeFileSync(secretsFilePath, JSON.stringify(encrypted, null, 2), { mode: 0o600 });
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Get a secret value (env var takes precedence)
|
|
330
|
+
*/
|
|
331
|
+
export function getSecret(key: string, packageName?: string): string | undefined {
|
|
332
|
+
// Environment variables take precedence (for CI/CD)
|
|
333
|
+
if (packageName) {
|
|
334
|
+
const scopedEnvKey = getScopedEnvVarName(packageName, key);
|
|
335
|
+
if (process.env[scopedEnvKey]) {
|
|
336
|
+
return process.env[scopedEnvKey];
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (process.env[key]) {
|
|
341
|
+
return process.env[key];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Check vault
|
|
345
|
+
const secrets = loadSecrets();
|
|
346
|
+
if (packageName) {
|
|
347
|
+
const scopedVaultKey = makeScopedVaultKey(packageName, key);
|
|
348
|
+
if (secrets[scopedVaultKey]) {
|
|
349
|
+
return secrets[scopedVaultKey];
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return secrets[key];
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Set a secret in the vault
|
|
358
|
+
*/
|
|
359
|
+
export function setSecret(key: string, value: string, packageName?: string): void {
|
|
360
|
+
const secrets = loadSecrets();
|
|
361
|
+
const vaultKey = packageName ? makeScopedVaultKey(packageName, key) : key;
|
|
362
|
+
secrets[vaultKey] = value;
|
|
363
|
+
saveSecrets(secrets);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Delete a secret from the vault
|
|
368
|
+
*/
|
|
369
|
+
export function deleteSecret(key: string, packageName?: string): boolean {
|
|
370
|
+
const secrets = loadSecrets();
|
|
371
|
+
const vaultKey = packageName ? makeScopedVaultKey(packageName, key) : key;
|
|
372
|
+
if (vaultKey in secrets) {
|
|
373
|
+
delete secrets[vaultKey];
|
|
374
|
+
saveSecrets(secrets);
|
|
375
|
+
return true;
|
|
376
|
+
}
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* List all secret keys (not values)
|
|
382
|
+
*/
|
|
383
|
+
export function listSecretKeys(): string[] {
|
|
384
|
+
const secrets = loadSecrets();
|
|
385
|
+
return Object.keys(secrets);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Check if a secret exists (in env or vault)
|
|
390
|
+
*/
|
|
391
|
+
export function hasSecret(key: string, packageName?: string): boolean {
|
|
392
|
+
return getSecretSource(key, packageName) !== 'missing';
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Get secret source (for display)
|
|
397
|
+
*/
|
|
398
|
+
export function getSecretSource(key: string, packageName?: string): SecretSource {
|
|
399
|
+
if (packageName) {
|
|
400
|
+
const scopedEnvKey = getScopedEnvVarName(packageName, key);
|
|
401
|
+
if (process.env[scopedEnvKey]) return 'env_scoped';
|
|
402
|
+
}
|
|
403
|
+
if (process.env[key]) return 'env';
|
|
404
|
+
|
|
405
|
+
const secrets = loadSecrets();
|
|
406
|
+
|
|
407
|
+
if (packageName) {
|
|
408
|
+
const scopedVaultKey = makeScopedVaultKey(packageName, key);
|
|
409
|
+
if (secrets[scopedVaultKey]) return 'vault_scoped';
|
|
410
|
+
}
|
|
411
|
+
if (secrets[key]) return 'vault';
|
|
412
|
+
|
|
413
|
+
return 'missing';
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Export secrets as environment variables (for subprocess)
|
|
418
|
+
*/
|
|
419
|
+
export function getSecretsAsEnv(keys: string[], packageName?: string): Record<string, string> {
|
|
420
|
+
const env: Record<string, string> = {};
|
|
421
|
+
|
|
422
|
+
for (const key of keys) {
|
|
423
|
+
const value = getSecret(key, packageName);
|
|
424
|
+
if (value) {
|
|
425
|
+
env[key] = value;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return env;
|
|
430
|
+
}
|