@fentz26/envcp 1.0.1 → 1.0.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.
Files changed (63) hide show
  1. package/README.md +79 -130
  2. package/__tests__/config.test.ts +65 -0
  3. package/__tests__/crypto.test.ts +76 -0
  4. package/__tests__/http.test.ts +49 -0
  5. package/__tests__/storage.test.ts +94 -0
  6. package/dist/adapters/base.d.ts +1 -2
  7. package/dist/adapters/base.d.ts.map +1 -1
  8. package/dist/adapters/base.js +139 -14
  9. package/dist/adapters/base.js.map +1 -1
  10. package/dist/adapters/gemini.d.ts +1 -0
  11. package/dist/adapters/gemini.d.ts.map +1 -1
  12. package/dist/adapters/gemini.js +13 -99
  13. package/dist/adapters/gemini.js.map +1 -1
  14. package/dist/adapters/openai.d.ts +1 -0
  15. package/dist/adapters/openai.d.ts.map +1 -1
  16. package/dist/adapters/openai.js +13 -99
  17. package/dist/adapters/openai.js.map +1 -1
  18. package/dist/adapters/rest.d.ts +1 -0
  19. package/dist/adapters/rest.d.ts.map +1 -1
  20. package/dist/adapters/rest.js +16 -13
  21. package/dist/adapters/rest.js.map +1 -1
  22. package/dist/cli/index.js +132 -196
  23. package/dist/cli/index.js.map +1 -1
  24. package/dist/config/manager.d.ts.map +1 -1
  25. package/dist/config/manager.js +4 -1
  26. package/dist/config/manager.js.map +1 -1
  27. package/dist/mcp/server.d.ts +1 -16
  28. package/dist/mcp/server.d.ts.map +1 -1
  29. package/dist/mcp/server.js +23 -511
  30. package/dist/mcp/server.js.map +1 -1
  31. package/dist/server/unified.d.ts +1 -0
  32. package/dist/server/unified.d.ts.map +1 -1
  33. package/dist/server/unified.js +31 -19
  34. package/dist/server/unified.js.map +1 -1
  35. package/dist/storage/index.d.ts +2 -0
  36. package/dist/storage/index.d.ts.map +1 -1
  37. package/dist/storage/index.js +18 -4
  38. package/dist/storage/index.js.map +1 -1
  39. package/dist/types.d.ts +10 -0
  40. package/dist/types.d.ts.map +1 -1
  41. package/dist/types.js +2 -0
  42. package/dist/types.js.map +1 -1
  43. package/dist/utils/http.d.ts +13 -1
  44. package/dist/utils/http.d.ts.map +1 -1
  45. package/dist/utils/http.js +65 -2
  46. package/dist/utils/http.js.map +1 -1
  47. package/dist/utils/session.d.ts.map +1 -1
  48. package/dist/utils/session.js +8 -3
  49. package/dist/utils/session.js.map +1 -1
  50. package/jest.config.js +11 -0
  51. package/package.json +4 -3
  52. package/src/adapters/base.ts +147 -16
  53. package/src/adapters/gemini.ts +19 -105
  54. package/src/adapters/openai.ts +19 -105
  55. package/src/adapters/rest.ts +19 -15
  56. package/src/cli/index.ts +135 -259
  57. package/src/config/manager.ts +4 -1
  58. package/src/mcp/server.ts +26 -582
  59. package/src/server/unified.ts +37 -23
  60. package/src/storage/index.ts +22 -6
  61. package/src/types.ts +2 -0
  62. package/src/utils/http.ts +76 -2
  63. package/src/utils/session.ts +13 -8
@@ -3,9 +3,8 @@ import { RESTAdapter } from '../adapters/rest.js';
3
3
  import { OpenAIAdapter } from '../adapters/openai.js';
4
4
  import { GeminiAdapter } from '../adapters/gemini.js';
5
5
  import { EnvCPServer } from '../mcp/server.js';
6
- import { setCorsHeaders, sendJson, parseBody, validateApiKey } from '../utils/http.js';
6
+ import { setCorsHeaders, sendJson, parseBody, validateApiKey, RateLimiter, rateLimitMiddleware } from '../utils/http.js';
7
7
  import * as http from 'http';
8
- import * as url from 'url';
9
8
 
10
9
  export class UnifiedServer {
11
10
  private config: EnvCPConfig;
@@ -18,6 +17,7 @@ export class UnifiedServer {
18
17
  private geminiAdapter: GeminiAdapter | null = null;
19
18
  private mcpServer: EnvCPServer | null = null;
20
19
  private httpServer: http.Server | null = null;
20
+ private rateLimiter: RateLimiter = new RateLimiter(60, 60000);
21
21
 
22
22
  constructor(config: EnvCPConfig, serverConfig: ServerConfig, projectPath: string, password?: string) {
23
23
  this.config = config;
@@ -29,8 +29,7 @@ export class UnifiedServer {
29
29
  // Detect client type from request headers
30
30
  detectClientType(req: http.IncomingMessage): ClientType {
31
31
  const userAgent = req.headers['user-agent']?.toLowerCase() || '';
32
- const contentType = req.headers['content-type'] || '';
33
- const pathname = url.parse(req.url || '/', true).pathname || '/';
32
+ const pathname = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`).pathname;
34
33
 
35
34
  // Check for OpenAI-style requests
36
35
  if (pathname.startsWith('/v1/chat') ||
@@ -111,7 +110,7 @@ export class UnifiedServer {
111
110
 
112
111
  // Auto or All mode - unified server that routes based on detection
113
112
  this.httpServer = http.createServer(async (req, res) => {
114
- setCorsHeaders(res);
113
+ setCorsHeaders(res, undefined, req.headers.origin);
115
114
 
116
115
  if (req.method === 'OPTIONS') {
117
116
  res.writeHead(204);
@@ -119,6 +118,10 @@ export class UnifiedServer {
119
118
  return;
120
119
  }
121
120
 
121
+ if (!rateLimitMiddleware(this.rateLimiter, req, res)) {
122
+ return;
123
+ }
124
+
122
125
  // API key validation
123
126
  if (api_key) {
124
127
  const providedKey = (req.headers['x-api-key'] ||
@@ -130,8 +133,8 @@ export class UnifiedServer {
130
133
  }
131
134
  }
132
135
 
133
- const parsedUrl = url.parse(req.url || '/', true);
134
- const pathname = parsedUrl.pathname || '/';
136
+ const parsedUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
137
+ const pathname = parsedUrl.pathname;
135
138
 
136
139
  // Root endpoint - show server info and detected mode
137
140
  if (pathname === '/' && req.method === 'GET') {
@@ -159,7 +162,7 @@ export class UnifiedServer {
159
162
  }
160
163
 
161
164
  // Force mode query param
162
- const forceMode = parsedUrl.query.mode as string | undefined;
165
+ const forceMode = parsedUrl.searchParams.get('mode') || undefined;
163
166
  if (forceMode && ['rest', 'openai', 'gemini'].includes(forceMode)) {
164
167
  clientType = forceMode as ClientType;
165
168
  }
@@ -201,11 +204,19 @@ export class UnifiedServer {
201
204
  },
202
205
  });
203
206
 
204
- } catch (error: any) {
205
- sendJson(res, 500, { error: error.message });
207
+ } catch (error: unknown) {
208
+ const message = error instanceof Error ? error.message : String(error);
209
+ sendJson(res, 500, { error: message });
206
210
  }
207
211
  });
208
212
 
213
+ const shutdown = () => {
214
+ this.stop();
215
+ process.exit(0);
216
+ };
217
+ process.on('SIGTERM', shutdown);
218
+ process.on('SIGINT', shutdown);
219
+
209
220
  return new Promise((resolve) => {
210
221
  this.httpServer!.listen(port, host, () => {
211
222
  resolve();
@@ -215,7 +226,7 @@ export class UnifiedServer {
215
226
 
216
227
  private async handleRESTRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
217
228
  // Delegate to REST adapter's internal handling
218
- const parsedUrl = url.parse(req.url || '/', true);
229
+ const parsedUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
219
230
  const pathname = parsedUrl.pathname || '/';
220
231
  const segments = pathname.split('/').filter(Boolean);
221
232
 
@@ -224,7 +235,7 @@ export class UnifiedServer {
224
235
  return;
225
236
  }
226
237
 
227
- const body = await parseBody(req);
238
+ const body = (req.method === 'POST' || req.method === 'PUT' || req.method === 'PATCH') ? await parseBody(req) : {};
228
239
 
229
240
  try {
230
241
  if (segments[0] === 'api') {
@@ -262,7 +273,7 @@ export class UnifiedServer {
262
273
  return;
263
274
  }
264
275
  if (segments[2] && req.method === 'GET') {
265
- const result = await this.restAdapter.callTool('envcp_get', { name: segments[2], show_value: parsedUrl.query.show_value === 'true' });
276
+ const result = await this.restAdapter.callTool('envcp_get', { name: segments[2], show_value: parsedUrl.searchParams.get('show_value') === 'true' });
266
277
  sendJson(res, 200, { success: true, data: result, timestamp: new Date().toISOString() });
267
278
  return;
268
279
  }
@@ -295,8 +306,9 @@ export class UnifiedServer {
295
306
 
296
307
  sendJson(res, 404, { success: false, error: 'Not found', timestamp: new Date().toISOString() });
297
308
 
298
- } catch (error: any) {
299
- sendJson(res, 500, { success: false, error: error.message, timestamp: new Date().toISOString() });
309
+ } catch (error: unknown) {
310
+ const message = error instanceof Error ? error.message : String(error);
311
+ sendJson(res, 500, { success: false, error: message, timestamp: new Date().toISOString() });
300
312
  }
301
313
  }
302
314
 
@@ -306,9 +318,9 @@ export class UnifiedServer {
306
318
  return;
307
319
  }
308
320
 
309
- const parsedUrl = url.parse(req.url || '/', true);
321
+ const parsedUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
310
322
  const pathname = parsedUrl.pathname || '/';
311
- const body = await parseBody(req);
323
+ const body = req.method === 'POST' ? await parseBody(req) : {};
312
324
 
313
325
  try {
314
326
  if (pathname === '/v1/models' && req.method === 'GET') {
@@ -366,8 +378,9 @@ export class UnifiedServer {
366
378
 
367
379
  sendJson(res, 404, { error: { message: 'Not found', type: 'not_found' } });
368
380
 
369
- } catch (error: any) {
370
- sendJson(res, 500, { error: { message: error.message, type: 'internal_error' } });
381
+ } catch (error: unknown) {
382
+ const message = error instanceof Error ? error.message : String(error);
383
+ sendJson(res, 500, { error: { message, type: 'internal_error' } });
371
384
  }
372
385
  }
373
386
 
@@ -377,9 +390,9 @@ export class UnifiedServer {
377
390
  return;
378
391
  }
379
392
 
380
- const parsedUrl = url.parse(req.url || '/', true);
393
+ const parsedUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
381
394
  const pathname = parsedUrl.pathname || '/';
382
- const body = await parseBody(req);
395
+ const body = req.method === 'POST' ? await parseBody(req) : {};
383
396
 
384
397
  try {
385
398
  if (pathname === '/v1/tools' && req.method === 'GET') {
@@ -436,8 +449,9 @@ export class UnifiedServer {
436
449
 
437
450
  sendJson(res, 404, { error: { code: 404, message: 'Not found', status: 'NOT_FOUND' } });
438
451
 
439
- } catch (error: any) {
440
- sendJson(res, 500, { error: { code: 500, message: error.message, status: 'INTERNAL' } });
452
+ } catch (error: unknown) {
453
+ const message = error instanceof Error ? error.message : String(error);
454
+ sendJson(res, 500, { error: { code: 500, message, status: 'INTERNAL' } });
441
455
  }
442
456
  }
443
457
 
@@ -7,6 +7,7 @@ export class StorageManager {
7
7
  private storePath: string;
8
8
  private encrypted: boolean;
9
9
  private password?: string;
10
+ private cache: Record<string, Variable> | null = null;
10
11
 
11
12
  constructor(storePath: string, encrypted: boolean = true) {
12
13
  this.storePath = storePath;
@@ -14,31 +15,42 @@ export class StorageManager {
14
15
  }
15
16
 
16
17
  setPassword(password: string): void {
17
- this.password = password;
18
+ if (this.password !== password) {
19
+ this.password = password;
20
+ this.cache = null;
21
+ }
18
22
  }
19
23
 
20
24
  async load(): Promise<Record<string, Variable>> {
25
+ if (this.cache !== null) {
26
+ return this.cache;
27
+ }
28
+
21
29
  if (!await fs.pathExists(this.storePath)) {
22
- return {};
30
+ this.cache = {};
31
+ return this.cache;
23
32
  }
24
33
 
25
34
  const data = await fs.readFile(this.storePath, 'utf8');
26
-
35
+
27
36
  if (this.encrypted && this.password) {
28
37
  try {
29
38
  const decrypted = decrypt(data, this.password);
30
- return JSON.parse(decrypted);
39
+ this.cache = JSON.parse(decrypted);
40
+ return this.cache!;
31
41
  } catch (error) {
32
42
  throw new Error('Failed to decrypt storage. Invalid password or corrupted data.');
33
43
  }
34
44
  }
35
45
 
36
- return JSON.parse(data);
46
+ this.cache = JSON.parse(data);
47
+ return this.cache!;
37
48
  }
38
49
 
39
50
  async save(variables: Record<string, Variable>): Promise<void> {
51
+ this.cache = variables;
40
52
  const data = JSON.stringify(variables, null, 2);
41
-
53
+
42
54
  await fs.ensureDir(path.dirname(this.storePath));
43
55
 
44
56
  if (this.encrypted && this.password) {
@@ -78,6 +90,10 @@ export class StorageManager {
78
90
  async exists(): Promise<boolean> {
79
91
  return fs.pathExists(this.storePath);
80
92
  }
93
+
94
+ invalidateCache(): void {
95
+ this.cache = null;
96
+ }
81
97
  }
82
98
 
83
99
  export class LogManager {
package/src/types.ts CHANGED
@@ -16,8 +16,10 @@ export const EnvCPConfigSchema = z.object({
16
16
  allow_ai_write: z.boolean().default(false),
17
17
  allow_ai_delete: z.boolean().default(false),
18
18
  allow_ai_export: z.boolean().default(false),
19
+ allow_ai_execute: z.boolean().default(false),
19
20
  allow_ai_active_check: z.boolean().default(false),
20
21
  require_user_reference: z.boolean().default(true),
22
+ allowed_commands: z.array(z.string()).optional(),
21
23
  require_confirmation: z.boolean().default(true),
22
24
  mask_values: z.boolean().default(true),
23
25
  audit_log: z.boolean().default(true),
package/src/utils/http.ts CHANGED
@@ -3,8 +3,14 @@ import * as http from 'http';
3
3
 
4
4
  const MAX_BODY_SIZE = 1024 * 1024; // 1MB
5
5
 
6
- export function setCorsHeaders(res: http.ServerResponse, allowedOrigin: string = '127.0.0.1'): void {
7
- res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
6
+ export function setCorsHeaders(res: http.ServerResponse, allowedOrigin?: string, requestOrigin?: string): void {
7
+ const localOrigins = ['http://127.0.0.1', 'http://localhost', 'http://[::1]'];
8
+ let origin = allowedOrigin || '*';
9
+ if (!allowedOrigin && requestOrigin) {
10
+ const matches = localOrigins.some(lo => requestOrigin.startsWith(lo));
11
+ origin = matches ? requestOrigin : '';
12
+ }
13
+ res.setHeader('Access-Control-Allow-Origin', origin);
8
14
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
9
15
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key, X-Goog-Api-Key, OpenAI-Organization');
10
16
  }
@@ -43,3 +49,71 @@ export function validateApiKey(provided: string | undefined, expected: string):
43
49
  if (provided.length !== expected.length) return false;
44
50
  return crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
45
51
  }
52
+
53
+ export class RateLimiter {
54
+ private requests: Map<string, number[]> = new Map();
55
+ private maxRequests: number;
56
+ private windowMs: number;
57
+ private cleanupTimer: ReturnType<typeof setInterval>;
58
+
59
+ constructor(maxRequests: number = 60, windowMs: number = 60000) {
60
+ this.maxRequests = maxRequests;
61
+ this.windowMs = windowMs;
62
+ // Periodically clean up stale entries
63
+ this.cleanupTimer = setInterval(() => this.cleanup(), windowMs * 2);
64
+ if (this.cleanupTimer.unref) this.cleanupTimer.unref();
65
+ }
66
+
67
+ isAllowed(key: string): boolean {
68
+ const now = Date.now();
69
+ const timestamps = this.requests.get(key) || [];
70
+ const recent = timestamps.filter(t => now - t < this.windowMs);
71
+
72
+ if (recent.length >= this.maxRequests) {
73
+ this.requests.set(key, recent);
74
+ return false;
75
+ }
76
+
77
+ recent.push(now);
78
+ this.requests.set(key, recent);
79
+ return true;
80
+ }
81
+
82
+ getRemainingRequests(key: string): number {
83
+ const now = Date.now();
84
+ const timestamps = this.requests.get(key) || [];
85
+ const recent = timestamps.filter(t => now - t < this.windowMs);
86
+ return Math.max(0, this.maxRequests - recent.length);
87
+ }
88
+
89
+ private cleanup(): void {
90
+ const now = Date.now();
91
+ for (const [key, timestamps] of this.requests) {
92
+ const recent = timestamps.filter(t => now - t < this.windowMs);
93
+ if (recent.length === 0) {
94
+ this.requests.delete(key);
95
+ } else {
96
+ this.requests.set(key, recent);
97
+ }
98
+ }
99
+ }
100
+
101
+ destroy(): void {
102
+ clearInterval(this.cleanupTimer);
103
+ }
104
+ }
105
+
106
+ export function rateLimitMiddleware(
107
+ limiter: RateLimiter,
108
+ req: http.IncomingMessage,
109
+ res: http.ServerResponse
110
+ ): boolean {
111
+ const key = req.socket.remoteAddress || 'unknown';
112
+ if (!limiter.isAllowed(key)) {
113
+ res.setHeader('Retry-After', '60');
114
+ sendJson(res, 429, { error: 'Too many requests' });
115
+ return false;
116
+ }
117
+ res.setHeader('X-RateLimit-Remaining', String(limiter.getRemainingRequests(key)));
118
+ return true;
119
+ }
@@ -2,6 +2,7 @@ import * as fs from 'fs-extra';
2
2
  import * as path from 'path';
3
3
  import { Session, SessionSchema } from '../types.js';
4
4
  import { generateId, encrypt, decrypt } from './crypto.js';
5
+ import * as crypto from 'crypto';
5
6
 
6
7
  export class SessionManager {
7
8
  private sessionPath: string;
@@ -23,7 +24,7 @@ export class SessionManager {
23
24
  async create(password: string): Promise<Session> {
24
25
  const now = new Date();
25
26
  const expires = new Date(now.getTime() + this.timeoutMinutes * 60 * 1000);
26
-
27
+
27
28
  this.session = {
28
29
  id: generateId(),
29
30
  created: now.toISOString(),
@@ -31,17 +32,19 @@ export class SessionManager {
31
32
  extensions: 0,
32
33
  last_access: now.toISOString(),
33
34
  };
34
-
35
+
35
36
  this.password = password;
36
-
37
+
38
+ // Store a verification hash instead of the raw password
39
+ const passwordHash = crypto.createHash('sha256').update(password).digest('hex');
37
40
  const sessionData = JSON.stringify({
38
41
  session: this.session,
39
- password: password,
42
+ passwordHash,
40
43
  });
41
-
44
+
42
45
  const encrypted = encrypt(sessionData, password);
43
46
  await fs.writeFile(this.sessionPath, encrypted, 'utf8');
44
-
47
+
45
48
  return this.session;
46
49
  }
47
50
 
@@ -61,7 +64,8 @@ export class SessionManager {
61
64
  const decrypted = decrypt(encrypted, pwd);
62
65
  const data = JSON.parse(decrypted);
63
66
  this.session = SessionSchema.parse(data.session);
64
- this.password = data.password;
67
+ // Password is verified by successful decryption — no longer stored in file
68
+ this.password = pwd;
65
69
 
66
70
  if (new Date() > new Date(this.session.expires)) {
67
71
  await this.destroy();
@@ -102,9 +106,10 @@ export class SessionManager {
102
106
  this.session.extensions += 1;
103
107
  this.session.last_access = now.toISOString();
104
108
 
109
+ const passwordHash = crypto.createHash('sha256').update(this.password).digest('hex');
105
110
  const sessionData = JSON.stringify({
106
111
  session: this.session,
107
- password: this.password,
112
+ passwordHash,
108
113
  });
109
114
 
110
115
  const encrypted = encrypt(sessionData, this.password);