cli4ai 1.2.0 → 1.2.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/README.md +39 -0
- package/dist/bin.d.ts +6 -0
- package/dist/bin.js +105 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +335 -0
- package/dist/commands/add.d.ts +11 -0
- package/dist/commands/add.js +464 -0
- package/dist/commands/browse.d.ts +4 -0
- package/dist/commands/browse.js +382 -0
- package/dist/commands/config.d.ts +10 -0
- package/dist/commands/config.js +121 -0
- package/dist/commands/info.d.ts +9 -0
- package/dist/commands/info.js +125 -0
- package/dist/commands/init.d.ts +10 -0
- package/dist/commands/init.js +458 -0
- package/dist/commands/list.d.ts +10 -0
- package/dist/commands/list.js +76 -0
- package/dist/commands/mcp-config.d.ts +10 -0
- package/dist/commands/mcp-config.js +49 -0
- package/dist/commands/remotes.d.ts +22 -0
- package/dist/commands/remotes.js +196 -0
- package/dist/commands/remove.d.ts +8 -0
- package/dist/commands/remove.js +61 -0
- package/dist/commands/routines.d.ts +29 -0
- package/dist/commands/routines.js +363 -0
- package/dist/commands/run.d.ts +12 -0
- package/dist/commands/run.js +104 -0
- package/dist/commands/scheduler.d.ts +27 -0
- package/dist/commands/scheduler.js +350 -0
- package/dist/commands/search.d.ts +9 -0
- package/dist/commands/search.js +162 -0
- package/dist/commands/secrets.d.ts +28 -0
- package/dist/commands/secrets.js +236 -0
- package/dist/commands/serve.d.ts +13 -0
- package/dist/commands/serve.js +49 -0
- package/dist/commands/start.d.ts +8 -0
- package/dist/commands/start.js +27 -0
- package/dist/commands/update.d.ts +17 -0
- package/dist/commands/update.js +210 -0
- package/dist/core/config.d.ts +91 -0
- package/dist/core/config.js +738 -0
- package/dist/core/execute.d.ts +51 -0
- package/dist/core/execute.js +475 -0
- package/dist/core/link.d.ts +39 -0
- package/dist/core/link.js +214 -0
- package/dist/core/lockfile.d.ts +63 -0
- package/dist/core/lockfile.js +140 -0
- package/dist/core/manifest.d.ts +96 -0
- package/dist/core/manifest.js +224 -0
- package/dist/core/registry.d.ts +74 -0
- package/dist/core/registry.js +116 -0
- package/dist/core/remote-client.d.ts +98 -0
- package/dist/core/remote-client.js +252 -0
- package/dist/core/remotes.d.ts +88 -0
- package/dist/core/remotes.js +206 -0
- package/dist/core/routine-engine.d.ts +124 -0
- package/dist/core/routine-engine.js +699 -0
- package/dist/core/routines.d.ts +36 -0
- package/dist/core/routines.js +132 -0
- package/dist/core/scheduler-daemon.d.ts +10 -0
- package/dist/core/scheduler-daemon.js +77 -0
- package/dist/core/scheduler.d.ts +131 -0
- package/dist/core/scheduler.js +492 -0
- package/dist/core/secrets.d.ts +48 -0
- package/dist/core/secrets.js +384 -0
- package/dist/lib/cli.d.ts +84 -0
- package/dist/lib/cli.js +216 -0
- package/dist/mcp/adapter.d.ts +35 -0
- package/dist/mcp/adapter.js +94 -0
- package/dist/mcp/config-gen.d.ts +31 -0
- package/dist/mcp/config-gen.js +75 -0
- package/dist/mcp/server.d.ts +41 -0
- package/dist/mcp/server.js +296 -0
- package/dist/server/service.d.ts +85 -0
- package/dist/server/service.js +304 -0
- package/package.json +6 -3
- package/src/bin.ts +0 -118
- package/src/cli.ts +0 -412
- package/src/commands/add.ts +0 -562
- package/src/commands/browse.ts +0 -449
- package/src/commands/config.ts +0 -154
- package/src/commands/info.ts +0 -133
- package/src/commands/init.ts +0 -514
- package/src/commands/list.ts +0 -95
- package/src/commands/mcp-config.ts +0 -69
- package/src/commands/remotes.ts +0 -253
- package/src/commands/remove.ts +0 -78
- package/src/commands/routines.ts +0 -427
- package/src/commands/run.ts +0 -127
- package/src/commands/scheduler.ts +0 -438
- package/src/commands/search.ts +0 -185
- package/src/commands/secrets.ts +0 -292
- package/src/commands/serve.ts +0 -66
- package/src/commands/start.ts +0 -40
- package/src/commands/update.ts +0 -252
- package/src/core/config.ts +0 -845
- package/src/core/execute.ts +0 -569
- package/src/core/link.ts +0 -246
- package/src/core/lockfile.ts +0 -187
- package/src/core/manifest.ts +0 -327
- package/src/core/registry.ts +0 -165
- package/src/core/remote-client.ts +0 -419
- package/src/core/remotes.ts +0 -268
- package/src/core/routine-engine.ts +0 -895
- package/src/core/routines.ts +0 -171
- package/src/core/scheduler-daemon.ts +0 -94
- package/src/core/scheduler.ts +0 -606
- package/src/core/secrets.ts +0 -430
- package/src/lib/cli.ts +0 -261
- package/src/mcp/adapter.ts +0 -131
- package/src/mcp/config-gen.ts +0 -106
- package/src/mcp/server.ts +0 -365
- package/src/server/service.ts +0 -434
|
@@ -0,0 +1,384 @@
|
|
|
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
|
+
import { readFileSync, writeFileSync, existsSync, chmodSync, statSync, mkdirSync } from 'fs';
|
|
21
|
+
import { createCipheriv, createDecipheriv, randomBytes, createHash, pbkdf2Sync } from 'crypto';
|
|
22
|
+
import { hostname, userInfo, platform } from 'os';
|
|
23
|
+
import { dirname, resolve } from 'path';
|
|
24
|
+
import { CLI4AI_HOME } from './config.js';
|
|
25
|
+
const SECRETS_FILE = resolve(CLI4AI_HOME, 'secrets.json');
|
|
26
|
+
const SALT_FILE = resolve(CLI4AI_HOME, 'secrets.salt');
|
|
27
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
28
|
+
const PBKDF2_ITERATIONS = 100000; // Strong iteration count for key derivation
|
|
29
|
+
const SECRETS_FILE_OVERRIDE_ENV = 'C4AI_SECRETS_FILE';
|
|
30
|
+
const SALT_FILE_OVERRIDE_ENV = 'C4AI_SECRETS_SALT_FILE';
|
|
31
|
+
function getSecretsFilePath() {
|
|
32
|
+
const override = process.env[SECRETS_FILE_OVERRIDE_ENV];
|
|
33
|
+
return override ? resolve(override) : SECRETS_FILE;
|
|
34
|
+
}
|
|
35
|
+
function getSaltFilePath() {
|
|
36
|
+
const override = process.env[SALT_FILE_OVERRIDE_ENV];
|
|
37
|
+
return override ? resolve(override) : SALT_FILE;
|
|
38
|
+
}
|
|
39
|
+
function ensureSecretsDir(filePath) {
|
|
40
|
+
const dir = dirname(filePath);
|
|
41
|
+
if (!existsSync(dir)) {
|
|
42
|
+
mkdirSync(dir, { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function normalizeEnvVarSegment(value) {
|
|
46
|
+
return value
|
|
47
|
+
.trim()
|
|
48
|
+
.toUpperCase()
|
|
49
|
+
.replace(/[^A-Z0-9]+/g, '_')
|
|
50
|
+
.replace(/^_+|_+$/g, '');
|
|
51
|
+
}
|
|
52
|
+
function getScopedEnvVarName(packageName, key) {
|
|
53
|
+
return `C4AI_${normalizeEnvVarSegment(packageName)}__${normalizeEnvVarSegment(key)}`;
|
|
54
|
+
}
|
|
55
|
+
function makeScopedVaultKey(packageName, key) {
|
|
56
|
+
return `${packageName}:${key}`;
|
|
57
|
+
}
|
|
58
|
+
function getLegacyMachineKey() {
|
|
59
|
+
const machineId = `${hostname()}-${userInfo().username}-cli4ai-secrets`;
|
|
60
|
+
return createHash('sha256').update(machineId).digest();
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Get or create the random salt for key derivation.
|
|
64
|
+
* The salt is stored in a separate file with restricted permissions.
|
|
65
|
+
*/
|
|
66
|
+
function getSaltIfExists() {
|
|
67
|
+
const saltFilePath = getSaltFilePath();
|
|
68
|
+
if (existsSync(saltFilePath)) {
|
|
69
|
+
// SECURITY: Verify file permissions on Unix-like systems
|
|
70
|
+
if (platform() !== 'win32') {
|
|
71
|
+
try {
|
|
72
|
+
const stats = statSync(saltFilePath);
|
|
73
|
+
const mode = stats.mode & 0o777;
|
|
74
|
+
if (mode !== 0o600) {
|
|
75
|
+
console.error(`Warning: Salt file has insecure permissions (${mode.toString(8)}). Should be 600.`);
|
|
76
|
+
// Attempt to fix permissions
|
|
77
|
+
chmodSync(saltFilePath, 0o600);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// Ignore permission check errors
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const salt = readFileSync(saltFilePath);
|
|
86
|
+
if (salt.length === 32) {
|
|
87
|
+
return salt;
|
|
88
|
+
}
|
|
89
|
+
// Invalid salt file, regenerate
|
|
90
|
+
console.error('Warning: Invalid salt file, regenerating...');
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// Failed to read, regenerate
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
function getOrCreateSalt() {
|
|
99
|
+
const saltFilePath = getSaltFilePath();
|
|
100
|
+
ensureSecretsDir(saltFilePath);
|
|
101
|
+
const salt = getSaltIfExists();
|
|
102
|
+
if (salt)
|
|
103
|
+
return salt;
|
|
104
|
+
// Generate new random salt
|
|
105
|
+
const newSalt = randomBytes(32);
|
|
106
|
+
writeFileSync(saltFilePath, newSalt, { mode: 0o600 });
|
|
107
|
+
return newSalt;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Try to get additional machine-specific entropy.
|
|
111
|
+
* This is best-effort and won't fail if sources are unavailable.
|
|
112
|
+
*/
|
|
113
|
+
function getMachineEntropy() {
|
|
114
|
+
const parts = [
|
|
115
|
+
hostname(),
|
|
116
|
+
userInfo().username,
|
|
117
|
+
];
|
|
118
|
+
// Try to get machine-id on Linux
|
|
119
|
+
if (platform() === 'linux') {
|
|
120
|
+
try {
|
|
121
|
+
const machineId = readFileSync('/etc/machine-id', 'utf-8').trim();
|
|
122
|
+
parts.push(machineId);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// Not available
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Try to get hardware UUID on macOS
|
|
129
|
+
if (platform() === 'darwin') {
|
|
130
|
+
try {
|
|
131
|
+
// This is a backup - the salt file is the primary entropy source
|
|
132
|
+
const { execSync } = require('child_process');
|
|
133
|
+
const uuid = execSync('ioreg -rd1 -c IOPlatformExpertDevice | grep IOPlatformUUID', {
|
|
134
|
+
encoding: 'utf-8',
|
|
135
|
+
timeout: 1000,
|
|
136
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
137
|
+
});
|
|
138
|
+
const match = uuid.match(/"([^"]+)"$/);
|
|
139
|
+
if (match) {
|
|
140
|
+
parts.push(match[1]);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// Not available
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return parts.join('-');
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Generate a machine-specific encryption key with strong entropy.
|
|
151
|
+
*
|
|
152
|
+
* Uses PBKDF2 with:
|
|
153
|
+
* - Machine-specific data (hostname, username, hardware IDs when available)
|
|
154
|
+
* - A random 32-byte salt stored on disk
|
|
155
|
+
* - 100,000 iterations for key stretching
|
|
156
|
+
*/
|
|
157
|
+
function getMachineKey(options) {
|
|
158
|
+
const salt = options.createSalt ? getOrCreateSalt() : getSaltIfExists();
|
|
159
|
+
if (!salt) {
|
|
160
|
+
throw new Error('Secrets salt file is missing');
|
|
161
|
+
}
|
|
162
|
+
const machineData = getMachineEntropy();
|
|
163
|
+
// Use PBKDF2 for proper key derivation with the random salt
|
|
164
|
+
return pbkdf2Sync(machineData, salt, PBKDF2_ITERATIONS, 32, 'sha512');
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Encrypt a string value
|
|
168
|
+
*/
|
|
169
|
+
function encrypt(text) {
|
|
170
|
+
const key = getMachineKey({ createSalt: true });
|
|
171
|
+
const iv = randomBytes(16);
|
|
172
|
+
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
173
|
+
let encrypted = cipher.update(text, 'utf8', 'hex');
|
|
174
|
+
encrypted += cipher.final('hex');
|
|
175
|
+
const authTag = cipher.getAuthTag();
|
|
176
|
+
return JSON.stringify({
|
|
177
|
+
iv: iv.toString('hex'),
|
|
178
|
+
encrypted,
|
|
179
|
+
authTag: authTag.toString('hex')
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
function decryptWithKey(data, key) {
|
|
183
|
+
const { iv, encrypted, authTag } = JSON.parse(data);
|
|
184
|
+
const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(iv, 'hex'));
|
|
185
|
+
decipher.setAuthTag(Buffer.from(authTag, 'hex'));
|
|
186
|
+
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
|
187
|
+
decrypted += decipher.final('utf8');
|
|
188
|
+
return decrypted;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Decrypt a string value
|
|
192
|
+
*/
|
|
193
|
+
function decrypt(data) {
|
|
194
|
+
const key = getMachineKey({ createSalt: false });
|
|
195
|
+
return decryptWithKey(data, key);
|
|
196
|
+
}
|
|
197
|
+
function decryptLegacy(data) {
|
|
198
|
+
const key = getLegacyMachineKey();
|
|
199
|
+
return decryptWithKey(data, key);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Check and warn about insecure file permissions
|
|
203
|
+
*/
|
|
204
|
+
function checkFilePermissions(filePath, fileName) {
|
|
205
|
+
if (platform() === 'win32')
|
|
206
|
+
return;
|
|
207
|
+
try {
|
|
208
|
+
const stats = statSync(filePath);
|
|
209
|
+
const mode = stats.mode & 0o777;
|
|
210
|
+
// Warn if file is readable by group or others
|
|
211
|
+
if (mode & 0o077) {
|
|
212
|
+
console.error(`Warning: ${fileName} has insecure permissions (${mode.toString(8)}). Should be 600.`);
|
|
213
|
+
// Attempt to fix permissions
|
|
214
|
+
try {
|
|
215
|
+
chmodSync(filePath, 0o600);
|
|
216
|
+
console.error(` Fixed permissions to 600.`);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
console.error(` Could not fix permissions. Please run: chmod 600 ${filePath}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
// Ignore errors
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Load all secrets from vault
|
|
229
|
+
*/
|
|
230
|
+
function loadSecrets() {
|
|
231
|
+
const secretsFilePath = getSecretsFilePath();
|
|
232
|
+
ensureSecretsDir(secretsFilePath);
|
|
233
|
+
if (!existsSync(secretsFilePath)) {
|
|
234
|
+
return {};
|
|
235
|
+
}
|
|
236
|
+
// SECURITY: Check file permissions
|
|
237
|
+
checkFilePermissions(secretsFilePath, 'secrets.json');
|
|
238
|
+
try {
|
|
239
|
+
const content = readFileSync(secretsFilePath, 'utf-8');
|
|
240
|
+
const encrypted = JSON.parse(content);
|
|
241
|
+
const secrets = {};
|
|
242
|
+
const migrated = {};
|
|
243
|
+
for (const [key, value] of Object.entries(encrypted)) {
|
|
244
|
+
if (typeof value !== 'string')
|
|
245
|
+
continue;
|
|
246
|
+
try {
|
|
247
|
+
secrets[key] = decrypt(value);
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
// Back-compat: attempt legacy decrypt (pre-salt secrets).
|
|
251
|
+
try {
|
|
252
|
+
const legacy = decryptLegacy(value);
|
|
253
|
+
secrets[key] = legacy;
|
|
254
|
+
migrated[key] = legacy;
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
// Skip corrupted/unreadable entries
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// If we successfully decrypted legacy secrets, rewrite those entries using the current scheme.
|
|
262
|
+
if (Object.keys(migrated).length > 0) {
|
|
263
|
+
try {
|
|
264
|
+
const updated = { ...encrypted };
|
|
265
|
+
for (const [k, v] of Object.entries(migrated)) {
|
|
266
|
+
updated[k] = encrypt(v);
|
|
267
|
+
}
|
|
268
|
+
writeFileSync(secretsFilePath, JSON.stringify(updated, null, 2), { mode: 0o600 });
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
// Best-effort migration only. If we can't write (e.g. restricted FS), still return decrypted secrets.
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return secrets;
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return {};
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Save all secrets to vault
|
|
282
|
+
*/
|
|
283
|
+
function saveSecrets(secrets) {
|
|
284
|
+
const secretsFilePath = getSecretsFilePath();
|
|
285
|
+
ensureSecretsDir(secretsFilePath);
|
|
286
|
+
const encrypted = {};
|
|
287
|
+
for (const [key, value] of Object.entries(secrets)) {
|
|
288
|
+
encrypted[key] = encrypt(value);
|
|
289
|
+
}
|
|
290
|
+
writeFileSync(secretsFilePath, JSON.stringify(encrypted, null, 2), { mode: 0o600 });
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Get a secret value (env var takes precedence)
|
|
294
|
+
*/
|
|
295
|
+
export function getSecret(key, packageName) {
|
|
296
|
+
// Environment variables take precedence (for CI/CD)
|
|
297
|
+
if (packageName) {
|
|
298
|
+
const scopedEnvKey = getScopedEnvVarName(packageName, key);
|
|
299
|
+
if (process.env[scopedEnvKey]) {
|
|
300
|
+
return process.env[scopedEnvKey];
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (process.env[key]) {
|
|
304
|
+
return process.env[key];
|
|
305
|
+
}
|
|
306
|
+
// Check vault
|
|
307
|
+
const secrets = loadSecrets();
|
|
308
|
+
if (packageName) {
|
|
309
|
+
const scopedVaultKey = makeScopedVaultKey(packageName, key);
|
|
310
|
+
if (secrets[scopedVaultKey]) {
|
|
311
|
+
return secrets[scopedVaultKey];
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return secrets[key];
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Set a secret in the vault
|
|
318
|
+
*/
|
|
319
|
+
export function setSecret(key, value, packageName) {
|
|
320
|
+
const secrets = loadSecrets();
|
|
321
|
+
const vaultKey = packageName ? makeScopedVaultKey(packageName, key) : key;
|
|
322
|
+
secrets[vaultKey] = value;
|
|
323
|
+
saveSecrets(secrets);
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Delete a secret from the vault
|
|
327
|
+
*/
|
|
328
|
+
export function deleteSecret(key, packageName) {
|
|
329
|
+
const secrets = loadSecrets();
|
|
330
|
+
const vaultKey = packageName ? makeScopedVaultKey(packageName, key) : key;
|
|
331
|
+
if (vaultKey in secrets) {
|
|
332
|
+
delete secrets[vaultKey];
|
|
333
|
+
saveSecrets(secrets);
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* List all secret keys (not values)
|
|
340
|
+
*/
|
|
341
|
+
export function listSecretKeys() {
|
|
342
|
+
const secrets = loadSecrets();
|
|
343
|
+
return Object.keys(secrets);
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Check if a secret exists (in env or vault)
|
|
347
|
+
*/
|
|
348
|
+
export function hasSecret(key, packageName) {
|
|
349
|
+
return getSecretSource(key, packageName) !== 'missing';
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Get secret source (for display)
|
|
353
|
+
*/
|
|
354
|
+
export function getSecretSource(key, packageName) {
|
|
355
|
+
if (packageName) {
|
|
356
|
+
const scopedEnvKey = getScopedEnvVarName(packageName, key);
|
|
357
|
+
if (process.env[scopedEnvKey])
|
|
358
|
+
return 'env_scoped';
|
|
359
|
+
}
|
|
360
|
+
if (process.env[key])
|
|
361
|
+
return 'env';
|
|
362
|
+
const secrets = loadSecrets();
|
|
363
|
+
if (packageName) {
|
|
364
|
+
const scopedVaultKey = makeScopedVaultKey(packageName, key);
|
|
365
|
+
if (secrets[scopedVaultKey])
|
|
366
|
+
return 'vault_scoped';
|
|
367
|
+
}
|
|
368
|
+
if (secrets[key])
|
|
369
|
+
return 'vault';
|
|
370
|
+
return 'missing';
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Export secrets as environment variables (for subprocess)
|
|
374
|
+
*/
|
|
375
|
+
export function getSecretsAsEnv(keys, packageName) {
|
|
376
|
+
const env = {};
|
|
377
|
+
for (const key of keys) {
|
|
378
|
+
const value = getSecret(key, packageName);
|
|
379
|
+
if (value) {
|
|
380
|
+
env[key] = value;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return env;
|
|
384
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli4ai - cli4ai.com
|
|
3
|
+
* Standardized CLI framework for AI agent tools
|
|
4
|
+
*/
|
|
5
|
+
import { Command } from 'commander';
|
|
6
|
+
export declare const BRAND = "cli4ai - cli4ai.com";
|
|
7
|
+
export declare const VERSION: any;
|
|
8
|
+
export interface CLIError {
|
|
9
|
+
error: string;
|
|
10
|
+
message: string;
|
|
11
|
+
[key: string]: unknown;
|
|
12
|
+
}
|
|
13
|
+
export type CommandFn = (...args: unknown[]) => Promise<void> | void;
|
|
14
|
+
export declare const ErrorCodes: {
|
|
15
|
+
readonly ENV_MISSING: "ENV_MISSING";
|
|
16
|
+
readonly INVALID_INPUT: "INVALID_INPUT";
|
|
17
|
+
readonly NOT_FOUND: "NOT_FOUND";
|
|
18
|
+
readonly AUTH_FAILED: "AUTH_FAILED";
|
|
19
|
+
readonly API_ERROR: "API_ERROR";
|
|
20
|
+
readonly NETWORK_ERROR: "NETWORK_ERROR";
|
|
21
|
+
readonly RATE_LIMITED: "RATE_LIMITED";
|
|
22
|
+
readonly TIMEOUT: "TIMEOUT";
|
|
23
|
+
readonly PARSE_ERROR: "PARSE_ERROR";
|
|
24
|
+
readonly MANIFEST_ERROR: "MANIFEST_ERROR";
|
|
25
|
+
readonly INSTALL_ERROR: "INSTALL_ERROR";
|
|
26
|
+
readonly NPM_ERROR: "NPM_ERROR";
|
|
27
|
+
};
|
|
28
|
+
export type ErrorCode = typeof ErrorCodes[keyof typeof ErrorCodes];
|
|
29
|
+
/**
|
|
30
|
+
* Require environment variables to be set. Exits with parseable error if missing.
|
|
31
|
+
*
|
|
32
|
+
* NOTE: Does not auto-load .env files. Use `cli4ai secrets` for secure credential storage.
|
|
33
|
+
*/
|
|
34
|
+
export declare function requireEnv(...variables: string[]): void;
|
|
35
|
+
/**
|
|
36
|
+
* Get env var or exit with error
|
|
37
|
+
*
|
|
38
|
+
* NOTE: Does not auto-load .env files. Use `cli4ai secrets` for secure credential storage.
|
|
39
|
+
*/
|
|
40
|
+
export declare function env(name: string): string;
|
|
41
|
+
/**
|
|
42
|
+
* Get env var or return default
|
|
43
|
+
*/
|
|
44
|
+
export declare function envOr(name: string, defaultValue: string): string;
|
|
45
|
+
/**
|
|
46
|
+
* Output JSON data to stdout
|
|
47
|
+
*/
|
|
48
|
+
export declare function output(data: unknown): void;
|
|
49
|
+
/**
|
|
50
|
+
* Output error and exit. Format is parseable JSON.
|
|
51
|
+
*/
|
|
52
|
+
export declare function outputError(code: string, message: string, details?: Record<string, unknown>, exitCodeOverride?: number): never;
|
|
53
|
+
/**
|
|
54
|
+
* Log to stderr (for progress messages)
|
|
55
|
+
*/
|
|
56
|
+
export declare function log(message: string): void;
|
|
57
|
+
/**
|
|
58
|
+
* Create a branded CLI program
|
|
59
|
+
*/
|
|
60
|
+
export declare function cli(name: string, version: string, description: string): Command;
|
|
61
|
+
/**
|
|
62
|
+
* Wrap an async action with error handling
|
|
63
|
+
*/
|
|
64
|
+
export declare function withErrorHandling<T extends unknown[]>(fn: (...args: T) => Promise<void>): (...args: T) => Promise<void>;
|
|
65
|
+
/**
|
|
66
|
+
* Parse a string as JSON, or return error
|
|
67
|
+
*/
|
|
68
|
+
export declare function parseJson<T>(str: string, context?: string): T;
|
|
69
|
+
/**
|
|
70
|
+
* Sleep for ms milliseconds
|
|
71
|
+
*/
|
|
72
|
+
export declare const sleep: (ms: number) => Promise<void>;
|
|
73
|
+
/**
|
|
74
|
+
* Format bytes to human readable
|
|
75
|
+
*/
|
|
76
|
+
export declare function formatBytes(bytes: number): string;
|
|
77
|
+
/**
|
|
78
|
+
* Format date to ISO string (date only)
|
|
79
|
+
*/
|
|
80
|
+
export declare function formatDate(date: Date | number | string): string;
|
|
81
|
+
/**
|
|
82
|
+
* Format date to ISO string (datetime)
|
|
83
|
+
*/
|
|
84
|
+
export declare function formatDateTime(date: Date | number | string): string;
|
package/dist/lib/cli.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli4ai - cli4ai.com
|
|
3
|
+
* Standardized CLI framework for AI agent tools
|
|
4
|
+
*/
|
|
5
|
+
import { Command } from 'commander';
|
|
6
|
+
import { readFileSync } from 'fs';
|
|
7
|
+
import { dirname, join } from 'path';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const pkgPath = join(__dirname, '..', '..', 'package.json');
|
|
11
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
12
|
+
export const BRAND = 'cli4ai - cli4ai.com';
|
|
13
|
+
export const VERSION = pkg.version;
|
|
14
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
15
|
+
// ERROR CODES
|
|
16
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
17
|
+
export const ErrorCodes = {
|
|
18
|
+
ENV_MISSING: 'ENV_MISSING',
|
|
19
|
+
INVALID_INPUT: 'INVALID_INPUT',
|
|
20
|
+
NOT_FOUND: 'NOT_FOUND',
|
|
21
|
+
AUTH_FAILED: 'AUTH_FAILED',
|
|
22
|
+
API_ERROR: 'API_ERROR',
|
|
23
|
+
NETWORK_ERROR: 'NETWORK_ERROR',
|
|
24
|
+
RATE_LIMITED: 'RATE_LIMITED',
|
|
25
|
+
TIMEOUT: 'TIMEOUT',
|
|
26
|
+
PARSE_ERROR: 'PARSE_ERROR',
|
|
27
|
+
MANIFEST_ERROR: 'MANIFEST_ERROR',
|
|
28
|
+
INSTALL_ERROR: 'INSTALL_ERROR',
|
|
29
|
+
NPM_ERROR: 'NPM_ERROR',
|
|
30
|
+
};
|
|
31
|
+
const EXIT_CODES = {
|
|
32
|
+
[ErrorCodes.NOT_FOUND]: 2,
|
|
33
|
+
[ErrorCodes.INVALID_INPUT]: 3,
|
|
34
|
+
[ErrorCodes.ENV_MISSING]: 4,
|
|
35
|
+
[ErrorCodes.MANIFEST_ERROR]: 4,
|
|
36
|
+
[ErrorCodes.INSTALL_ERROR]: 4,
|
|
37
|
+
[ErrorCodes.AUTH_FAILED]: 6,
|
|
38
|
+
[ErrorCodes.NETWORK_ERROR]: 7,
|
|
39
|
+
[ErrorCodes.RATE_LIMITED]: 8,
|
|
40
|
+
[ErrorCodes.TIMEOUT]: 9,
|
|
41
|
+
[ErrorCodes.PARSE_ERROR]: 10,
|
|
42
|
+
[ErrorCodes.NPM_ERROR]: 11,
|
|
43
|
+
};
|
|
44
|
+
function getExitCode(code) {
|
|
45
|
+
return EXIT_CODES[code] ?? 1;
|
|
46
|
+
}
|
|
47
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
48
|
+
// ENV VALIDATION
|
|
49
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
50
|
+
// SECURITY NOTE: We intentionally do NOT auto-load .env files from the filesystem.
|
|
51
|
+
// This prevents supply chain attacks where malicious .env files in parent directories
|
|
52
|
+
// could inject credentials or override security settings.
|
|
53
|
+
//
|
|
54
|
+
// Use `cli4ai secrets set <key>` for secure credential storage, or set environment
|
|
55
|
+
// variables explicitly in your shell/CI environment.
|
|
56
|
+
/**
|
|
57
|
+
* Require environment variables to be set. Exits with parseable error if missing.
|
|
58
|
+
*
|
|
59
|
+
* NOTE: Does not auto-load .env files. Use `cli4ai secrets` for secure credential storage.
|
|
60
|
+
*/
|
|
61
|
+
export function requireEnv(...variables) {
|
|
62
|
+
const missing = variables.filter(v => !process.env[v]);
|
|
63
|
+
if (missing.length > 0) {
|
|
64
|
+
outputError('ENV_MISSING', `Missing required environment variables: ${missing.join(', ')}`, {
|
|
65
|
+
variables: missing,
|
|
66
|
+
hint: 'Use "cli4ai secrets set <key>" to store securely, or set in your shell environment'
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Get env var or exit with error
|
|
72
|
+
*
|
|
73
|
+
* NOTE: Does not auto-load .env files. Use `cli4ai secrets` for secure credential storage.
|
|
74
|
+
*/
|
|
75
|
+
export function env(name) {
|
|
76
|
+
const value = process.env[name];
|
|
77
|
+
if (!value) {
|
|
78
|
+
outputError('ENV_MISSING', `Missing required environment variable: ${name}`, {
|
|
79
|
+
variables: [name],
|
|
80
|
+
hint: 'Use "cli4ai secrets set ' + name + '" to store securely, or set in your shell environment'
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Get env var or return default
|
|
87
|
+
*/
|
|
88
|
+
export function envOr(name, defaultValue) {
|
|
89
|
+
return process.env[name] || defaultValue;
|
|
90
|
+
}
|
|
91
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
92
|
+
// OUTPUT
|
|
93
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
94
|
+
/**
|
|
95
|
+
* Output JSON data to stdout
|
|
96
|
+
*/
|
|
97
|
+
export function output(data) {
|
|
98
|
+
console.log(JSON.stringify(data, null, 2));
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Output error and exit. Format is parseable JSON.
|
|
102
|
+
*/
|
|
103
|
+
export function outputError(code, message, details, exitCodeOverride) {
|
|
104
|
+
console.error(JSON.stringify({
|
|
105
|
+
error: code,
|
|
106
|
+
message,
|
|
107
|
+
...details
|
|
108
|
+
}));
|
|
109
|
+
process.exit(exitCodeOverride ?? getExitCode(code));
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Log to stderr (for progress messages)
|
|
113
|
+
*/
|
|
114
|
+
export function log(message) {
|
|
115
|
+
console.error(message);
|
|
116
|
+
}
|
|
117
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
118
|
+
// CLI CREATION
|
|
119
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
120
|
+
/**
|
|
121
|
+
* Create a branded CLI program
|
|
122
|
+
*/
|
|
123
|
+
export function cli(name, version, description) {
|
|
124
|
+
const program = new Command()
|
|
125
|
+
.name(name)
|
|
126
|
+
.version(version, '-v, --version', 'Show version')
|
|
127
|
+
.description(description)
|
|
128
|
+
.addHelpText('beforeAll', `\n${BRAND}\n`)
|
|
129
|
+
.configureOutput({
|
|
130
|
+
writeErr: (str) => {
|
|
131
|
+
// Don't double-output errors
|
|
132
|
+
if (!str.includes('"error"')) {
|
|
133
|
+
process.stderr.write(str);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
.exitOverride((err) => {
|
|
138
|
+
if (err.code === 'commander.helpDisplayed' || err.code === 'commander.version') {
|
|
139
|
+
process.exit(0);
|
|
140
|
+
}
|
|
141
|
+
if (err.code === 'commander.missingArgument') {
|
|
142
|
+
outputError('INVALID_INPUT', err.message, { code: err.code });
|
|
143
|
+
}
|
|
144
|
+
if (err.code === 'commander.unknownCommand') {
|
|
145
|
+
outputError('INVALID_INPUT', err.message, { code: err.code });
|
|
146
|
+
}
|
|
147
|
+
if (err.code !== 'commander.help') {
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
program.addHelpCommand('help [command]', 'Show help for command');
|
|
152
|
+
return program;
|
|
153
|
+
}
|
|
154
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
155
|
+
// ERROR HANDLING
|
|
156
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
157
|
+
/**
|
|
158
|
+
* Wrap an async action with error handling
|
|
159
|
+
*/
|
|
160
|
+
export function withErrorHandling(fn) {
|
|
161
|
+
return async (...args) => {
|
|
162
|
+
try {
|
|
163
|
+
await fn(...args);
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
167
|
+
outputError('API_ERROR', message);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
172
|
+
// UTILITY HELPERS
|
|
173
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
174
|
+
/**
|
|
175
|
+
* Parse a string as JSON, or return error
|
|
176
|
+
*/
|
|
177
|
+
export function parseJson(str, context) {
|
|
178
|
+
try {
|
|
179
|
+
return JSON.parse(str);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
outputError('PARSE_ERROR', `Invalid JSON${context ? ` in ${context}` : ''}`, {
|
|
183
|
+
input: str.slice(0, 100)
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Sleep for ms milliseconds
|
|
189
|
+
*/
|
|
190
|
+
export const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
191
|
+
/**
|
|
192
|
+
* Format bytes to human readable
|
|
193
|
+
*/
|
|
194
|
+
export function formatBytes(bytes) {
|
|
195
|
+
if (bytes < 1024)
|
|
196
|
+
return `${bytes} B`;
|
|
197
|
+
if (bytes < 1024 * 1024)
|
|
198
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
199
|
+
if (bytes < 1024 * 1024 * 1024)
|
|
200
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
201
|
+
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Format date to ISO string (date only)
|
|
205
|
+
*/
|
|
206
|
+
export function formatDate(date) {
|
|
207
|
+
const d = new Date(date);
|
|
208
|
+
return d.toISOString().slice(0, 10);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Format date to ISO string (datetime)
|
|
212
|
+
*/
|
|
213
|
+
export function formatDateTime(date) {
|
|
214
|
+
const d = new Date(date);
|
|
215
|
+
return d.toISOString().slice(0, 19).replace('T', ' ');
|
|
216
|
+
}
|