@openqa/cli 2.0.0 → 2.1.1
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 +202 -5
- package/dist/agent/index-v2.js +33 -55
- package/dist/agent/index-v2.js.map +1 -1
- package/dist/agent/index.js +85 -116
- package/dist/agent/index.js.map +1 -1
- package/dist/cli/daemon.js +1530 -277
- package/dist/cli/dashboard.html.js +55 -15
- package/dist/cli/env-config.js +391 -0
- package/dist/cli/env-routes.js +820 -0
- package/dist/cli/env.html.js +679 -0
- package/dist/cli/index.js +4568 -2317
- package/dist/cli/server.js +2212 -19
- package/install.sh +19 -10
- package/package.json +2 -1
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
// cli/env-routes.ts
|
|
2
|
+
import { Router } from "express";
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
|
|
6
|
+
// cli/env-config.ts
|
|
7
|
+
var ENV_VARIABLES = [
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// LLM CONFIGURATION
|
|
10
|
+
// ============================================================================
|
|
11
|
+
{
|
|
12
|
+
key: "LLM_PROVIDER",
|
|
13
|
+
type: "select",
|
|
14
|
+
category: "llm",
|
|
15
|
+
required: true,
|
|
16
|
+
description: "LLM provider to use for AI operations",
|
|
17
|
+
options: ["openai", "anthropic", "ollama"],
|
|
18
|
+
placeholder: "openai",
|
|
19
|
+
restartRequired: true
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
key: "OPENAI_API_KEY",
|
|
23
|
+
type: "password",
|
|
24
|
+
category: "llm",
|
|
25
|
+
required: false,
|
|
26
|
+
description: "OpenAI API key (required if LLM_PROVIDER=openai)",
|
|
27
|
+
placeholder: "sk-...",
|
|
28
|
+
sensitive: true,
|
|
29
|
+
testable: true,
|
|
30
|
+
validation: (value) => {
|
|
31
|
+
if (!value) return { valid: true };
|
|
32
|
+
if (!value.startsWith("sk-")) {
|
|
33
|
+
return { valid: false, error: 'OpenAI API key must start with "sk-"' };
|
|
34
|
+
}
|
|
35
|
+
if (value.length < 20) {
|
|
36
|
+
return { valid: false, error: "API key seems too short" };
|
|
37
|
+
}
|
|
38
|
+
return { valid: true };
|
|
39
|
+
},
|
|
40
|
+
restartRequired: true
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
key: "ANTHROPIC_API_KEY",
|
|
44
|
+
type: "password",
|
|
45
|
+
category: "llm",
|
|
46
|
+
required: false,
|
|
47
|
+
description: "Anthropic API key (required if LLM_PROVIDER=anthropic)",
|
|
48
|
+
placeholder: "sk-ant-...",
|
|
49
|
+
sensitive: true,
|
|
50
|
+
testable: true,
|
|
51
|
+
validation: (value) => {
|
|
52
|
+
if (!value) return { valid: true };
|
|
53
|
+
if (!value.startsWith("sk-ant-")) {
|
|
54
|
+
return { valid: false, error: 'Anthropic API key must start with "sk-ant-"' };
|
|
55
|
+
}
|
|
56
|
+
return { valid: true };
|
|
57
|
+
},
|
|
58
|
+
restartRequired: true
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
key: "OLLAMA_BASE_URL",
|
|
62
|
+
type: "url",
|
|
63
|
+
category: "llm",
|
|
64
|
+
required: false,
|
|
65
|
+
description: "Ollama server URL (required if LLM_PROVIDER=ollama)",
|
|
66
|
+
placeholder: "http://localhost:11434",
|
|
67
|
+
testable: true,
|
|
68
|
+
validation: (value) => {
|
|
69
|
+
if (!value) return { valid: true };
|
|
70
|
+
try {
|
|
71
|
+
new URL(value);
|
|
72
|
+
return { valid: true };
|
|
73
|
+
} catch {
|
|
74
|
+
return { valid: false, error: "Invalid URL format" };
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
restartRequired: true
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
key: "LLM_MODEL",
|
|
81
|
+
type: "text",
|
|
82
|
+
category: "llm",
|
|
83
|
+
required: false,
|
|
84
|
+
description: "LLM model to use (e.g., gpt-4, claude-3-opus, llama2)",
|
|
85
|
+
placeholder: "gpt-4",
|
|
86
|
+
restartRequired: true
|
|
87
|
+
},
|
|
88
|
+
// ============================================================================
|
|
89
|
+
// SECURITY
|
|
90
|
+
// ============================================================================
|
|
91
|
+
{
|
|
92
|
+
key: "OPENQA_JWT_SECRET",
|
|
93
|
+
type: "password",
|
|
94
|
+
category: "security",
|
|
95
|
+
required: true,
|
|
96
|
+
description: "Secret key for JWT token signing (min 32 characters)",
|
|
97
|
+
placeholder: "Generate with: openssl rand -hex 32",
|
|
98
|
+
sensitive: true,
|
|
99
|
+
validation: (value) => {
|
|
100
|
+
if (!value) return { valid: false, error: "JWT secret is required" };
|
|
101
|
+
if (value.length < 32) {
|
|
102
|
+
return { valid: false, error: "JWT secret must be at least 32 characters" };
|
|
103
|
+
}
|
|
104
|
+
return { valid: true };
|
|
105
|
+
},
|
|
106
|
+
restartRequired: true
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
key: "OPENQA_AUTH_DISABLED",
|
|
110
|
+
type: "boolean",
|
|
111
|
+
category: "security",
|
|
112
|
+
required: false,
|
|
113
|
+
description: "\u26A0\uFE0F DANGER: Disable authentication (NEVER use in production!)",
|
|
114
|
+
placeholder: "false",
|
|
115
|
+
validation: (value) => {
|
|
116
|
+
if (value === "true" && process.env.NODE_ENV === "production") {
|
|
117
|
+
return { valid: false, error: "Cannot disable auth in production!" };
|
|
118
|
+
}
|
|
119
|
+
return { valid: true };
|
|
120
|
+
},
|
|
121
|
+
restartRequired: true
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
key: "NODE_ENV",
|
|
125
|
+
type: "select",
|
|
126
|
+
category: "security",
|
|
127
|
+
required: false,
|
|
128
|
+
description: "Node environment (production enables security features)",
|
|
129
|
+
options: ["development", "production", "test"],
|
|
130
|
+
placeholder: "production",
|
|
131
|
+
restartRequired: true
|
|
132
|
+
},
|
|
133
|
+
// ============================================================================
|
|
134
|
+
// TARGET APPLICATION
|
|
135
|
+
// ============================================================================
|
|
136
|
+
{
|
|
137
|
+
key: "SAAS_URL",
|
|
138
|
+
type: "url",
|
|
139
|
+
category: "target",
|
|
140
|
+
required: true,
|
|
141
|
+
description: "URL of the application to test",
|
|
142
|
+
placeholder: "https://your-app.com",
|
|
143
|
+
testable: true,
|
|
144
|
+
validation: (value) => {
|
|
145
|
+
if (!value) return { valid: false, error: "Target URL is required" };
|
|
146
|
+
try {
|
|
147
|
+
const url = new URL(value);
|
|
148
|
+
if (!["http:", "https:"].includes(url.protocol)) {
|
|
149
|
+
return { valid: false, error: "URL must use http or https protocol" };
|
|
150
|
+
}
|
|
151
|
+
return { valid: true };
|
|
152
|
+
} catch {
|
|
153
|
+
return { valid: false, error: "Invalid URL format" };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
key: "SAAS_AUTH_TYPE",
|
|
159
|
+
type: "select",
|
|
160
|
+
category: "target",
|
|
161
|
+
required: false,
|
|
162
|
+
description: "Authentication type for target application",
|
|
163
|
+
options: ["none", "basic", "session"],
|
|
164
|
+
placeholder: "none"
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
key: "SAAS_USERNAME",
|
|
168
|
+
type: "text",
|
|
169
|
+
category: "target",
|
|
170
|
+
required: false,
|
|
171
|
+
description: "Username for target application authentication",
|
|
172
|
+
placeholder: "test@example.com"
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
key: "SAAS_PASSWORD",
|
|
176
|
+
type: "password",
|
|
177
|
+
category: "target",
|
|
178
|
+
required: false,
|
|
179
|
+
description: "Password for target application authentication",
|
|
180
|
+
placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",
|
|
181
|
+
sensitive: true
|
|
182
|
+
},
|
|
183
|
+
// ============================================================================
|
|
184
|
+
// GITHUB INTEGRATION
|
|
185
|
+
// ============================================================================
|
|
186
|
+
{
|
|
187
|
+
key: "GITHUB_TOKEN",
|
|
188
|
+
type: "password",
|
|
189
|
+
category: "github",
|
|
190
|
+
required: false,
|
|
191
|
+
description: "GitHub personal access token for issue creation",
|
|
192
|
+
placeholder: "ghp_...",
|
|
193
|
+
sensitive: true,
|
|
194
|
+
testable: true,
|
|
195
|
+
validation: (value) => {
|
|
196
|
+
if (!value) return { valid: true };
|
|
197
|
+
if (!value.startsWith("ghp_") && !value.startsWith("github_pat_")) {
|
|
198
|
+
return { valid: false, error: 'GitHub token must start with "ghp_" or "github_pat_"' };
|
|
199
|
+
}
|
|
200
|
+
return { valid: true };
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
key: "GITHUB_OWNER",
|
|
205
|
+
type: "text",
|
|
206
|
+
category: "github",
|
|
207
|
+
required: false,
|
|
208
|
+
description: "GitHub repository owner/organization",
|
|
209
|
+
placeholder: "your-username"
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
key: "GITHUB_REPO",
|
|
213
|
+
type: "text",
|
|
214
|
+
category: "github",
|
|
215
|
+
required: false,
|
|
216
|
+
description: "GitHub repository name",
|
|
217
|
+
placeholder: "your-repo"
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
key: "GITHUB_BRANCH",
|
|
221
|
+
type: "text",
|
|
222
|
+
category: "github",
|
|
223
|
+
required: false,
|
|
224
|
+
description: "GitHub branch to monitor",
|
|
225
|
+
placeholder: "main"
|
|
226
|
+
},
|
|
227
|
+
// ============================================================================
|
|
228
|
+
// WEB SERVER
|
|
229
|
+
// ============================================================================
|
|
230
|
+
{
|
|
231
|
+
key: "WEB_PORT",
|
|
232
|
+
type: "number",
|
|
233
|
+
category: "web",
|
|
234
|
+
required: false,
|
|
235
|
+
description: "Port for web server",
|
|
236
|
+
placeholder: "4242",
|
|
237
|
+
validation: (value) => {
|
|
238
|
+
if (!value) return { valid: true };
|
|
239
|
+
const port = parseInt(value, 10);
|
|
240
|
+
if (isNaN(port) || port < 1 || port > 65535) {
|
|
241
|
+
return { valid: false, error: "Port must be between 1 and 65535" };
|
|
242
|
+
}
|
|
243
|
+
return { valid: true };
|
|
244
|
+
},
|
|
245
|
+
restartRequired: true
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
key: "WEB_HOST",
|
|
249
|
+
type: "text",
|
|
250
|
+
category: "web",
|
|
251
|
+
required: false,
|
|
252
|
+
description: "Host to bind web server (0.0.0.0 for all interfaces)",
|
|
253
|
+
placeholder: "0.0.0.0",
|
|
254
|
+
restartRequired: true
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
key: "CORS_ORIGINS",
|
|
258
|
+
type: "text",
|
|
259
|
+
category: "web",
|
|
260
|
+
required: false,
|
|
261
|
+
description: "Allowed CORS origins (comma-separated)",
|
|
262
|
+
placeholder: "https://your-domain.com,https://app.example.com",
|
|
263
|
+
restartRequired: true
|
|
264
|
+
},
|
|
265
|
+
// ============================================================================
|
|
266
|
+
// AGENT CONFIGURATION
|
|
267
|
+
// ============================================================================
|
|
268
|
+
{
|
|
269
|
+
key: "AGENT_AUTO_START",
|
|
270
|
+
type: "boolean",
|
|
271
|
+
category: "agent",
|
|
272
|
+
required: false,
|
|
273
|
+
description: "Auto-start agent on server launch",
|
|
274
|
+
placeholder: "false"
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
key: "AGENT_INTERVAL_MS",
|
|
278
|
+
type: "number",
|
|
279
|
+
category: "agent",
|
|
280
|
+
required: false,
|
|
281
|
+
description: "Agent run interval in milliseconds (1 hour = 3600000)",
|
|
282
|
+
placeholder: "3600000",
|
|
283
|
+
validation: (value) => {
|
|
284
|
+
if (!value) return { valid: true };
|
|
285
|
+
const interval = parseInt(value, 10);
|
|
286
|
+
if (isNaN(interval) || interval < 6e4) {
|
|
287
|
+
return { valid: false, error: "Interval must be at least 60000ms (1 minute)" };
|
|
288
|
+
}
|
|
289
|
+
return { valid: true };
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
key: "AGENT_MAX_ITERATIONS",
|
|
294
|
+
type: "number",
|
|
295
|
+
category: "agent",
|
|
296
|
+
required: false,
|
|
297
|
+
description: "Maximum iterations per agent session",
|
|
298
|
+
placeholder: "20",
|
|
299
|
+
validation: (value) => {
|
|
300
|
+
if (!value) return { valid: true };
|
|
301
|
+
const max = parseInt(value, 10);
|
|
302
|
+
if (isNaN(max) || max < 1 || max > 1e3) {
|
|
303
|
+
return { valid: false, error: "Max iterations must be between 1 and 1000" };
|
|
304
|
+
}
|
|
305
|
+
return { valid: true };
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
key: "GIT_LISTENER_ENABLED",
|
|
310
|
+
type: "boolean",
|
|
311
|
+
category: "agent",
|
|
312
|
+
required: false,
|
|
313
|
+
description: "Enable git merge/pipeline detection",
|
|
314
|
+
placeholder: "true"
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
key: "GIT_POLL_INTERVAL_MS",
|
|
318
|
+
type: "number",
|
|
319
|
+
category: "agent",
|
|
320
|
+
required: false,
|
|
321
|
+
description: "Git polling interval in milliseconds",
|
|
322
|
+
placeholder: "60000"
|
|
323
|
+
},
|
|
324
|
+
// ============================================================================
|
|
325
|
+
// DATABASE
|
|
326
|
+
// ============================================================================
|
|
327
|
+
{
|
|
328
|
+
key: "DB_PATH",
|
|
329
|
+
type: "text",
|
|
330
|
+
category: "database",
|
|
331
|
+
required: false,
|
|
332
|
+
description: "Path to SQLite database file",
|
|
333
|
+
placeholder: "./data/openqa.db",
|
|
334
|
+
restartRequired: true
|
|
335
|
+
},
|
|
336
|
+
// ============================================================================
|
|
337
|
+
// NOTIFICATIONS
|
|
338
|
+
// ============================================================================
|
|
339
|
+
{
|
|
340
|
+
key: "SLACK_WEBHOOK_URL",
|
|
341
|
+
type: "url",
|
|
342
|
+
category: "notifications",
|
|
343
|
+
required: false,
|
|
344
|
+
description: "Slack webhook URL for notifications",
|
|
345
|
+
placeholder: "https://hooks.slack.com/services/...",
|
|
346
|
+
sensitive: true,
|
|
347
|
+
testable: true,
|
|
348
|
+
validation: (value) => {
|
|
349
|
+
if (!value) return { valid: true };
|
|
350
|
+
if (!value.startsWith("https://hooks.slack.com/")) {
|
|
351
|
+
return { valid: false, error: "Invalid Slack webhook URL" };
|
|
352
|
+
}
|
|
353
|
+
return { valid: true };
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
key: "DISCORD_WEBHOOK_URL",
|
|
358
|
+
type: "url",
|
|
359
|
+
category: "notifications",
|
|
360
|
+
required: false,
|
|
361
|
+
description: "Discord webhook URL for notifications",
|
|
362
|
+
placeholder: "https://discord.com/api/webhooks/...",
|
|
363
|
+
sensitive: true,
|
|
364
|
+
testable: true,
|
|
365
|
+
validation: (value) => {
|
|
366
|
+
if (!value) return { valid: true };
|
|
367
|
+
if (!value.startsWith("https://discord.com/api/webhooks/")) {
|
|
368
|
+
return { valid: false, error: "Invalid Discord webhook URL" };
|
|
369
|
+
}
|
|
370
|
+
return { valid: true };
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
];
|
|
374
|
+
function getEnvVariable(key) {
|
|
375
|
+
return ENV_VARIABLES.find((v) => v.key === key);
|
|
376
|
+
}
|
|
377
|
+
function validateEnvValue(key, value) {
|
|
378
|
+
const envVar = getEnvVariable(key);
|
|
379
|
+
if (!envVar) return { valid: false, error: "Unknown environment variable" };
|
|
380
|
+
if (envVar.required && !value) {
|
|
381
|
+
return { valid: false, error: "This field is required" };
|
|
382
|
+
}
|
|
383
|
+
if (envVar.validation) {
|
|
384
|
+
return envVar.validation(value);
|
|
385
|
+
}
|
|
386
|
+
return { valid: true };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// cli/auth/jwt.ts
|
|
390
|
+
import { createHmac, randomBytes } from "crypto";
|
|
391
|
+
import { parse as parseCookies } from "cookie";
|
|
392
|
+
var TTL_MS = 24 * 60 * 60 * 1e3;
|
|
393
|
+
var COOKIE_NAME = "openqa_token";
|
|
394
|
+
var _secret = null;
|
|
395
|
+
function getSecret() {
|
|
396
|
+
if (_secret) return _secret;
|
|
397
|
+
_secret = process.env.OPENQA_JWT_SECRET ?? null;
|
|
398
|
+
if (!_secret) {
|
|
399
|
+
_secret = randomBytes(32).toString("hex");
|
|
400
|
+
console.warn("[OpenQA] OPENQA_JWT_SECRET not set \u2014 using a volatile secret. All sessions will be invalidated on restart.");
|
|
401
|
+
}
|
|
402
|
+
return _secret;
|
|
403
|
+
}
|
|
404
|
+
function fromB64url(input) {
|
|
405
|
+
return Buffer.from(input, "base64url").toString("utf8");
|
|
406
|
+
}
|
|
407
|
+
function verifyToken(token) {
|
|
408
|
+
try {
|
|
409
|
+
const [header, body, sig] = token.split(".");
|
|
410
|
+
if (!header || !body || !sig) return null;
|
|
411
|
+
const expected = createHmac("sha256", getSecret()).update(`${header}.${body}`).digest("base64url");
|
|
412
|
+
if (expected !== sig) return null;
|
|
413
|
+
const payload = JSON.parse(fromB64url(body));
|
|
414
|
+
if (payload.exp < Math.floor(Date.now() / 1e3)) return null;
|
|
415
|
+
return payload;
|
|
416
|
+
} catch {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
function extractToken(req) {
|
|
421
|
+
const cookieHeader = req.headers.cookie ?? "";
|
|
422
|
+
if (cookieHeader) {
|
|
423
|
+
const cookies = parseCookies(cookieHeader);
|
|
424
|
+
if (cookies[COOKIE_NAME]) return cookies[COOKIE_NAME];
|
|
425
|
+
}
|
|
426
|
+
const auth = req.headers.authorization ?? "";
|
|
427
|
+
if (auth.startsWith("Bearer ")) return auth.slice(7);
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// cli/auth/middleware.ts
|
|
432
|
+
var AUTH_DISABLED = process.env.OPENQA_AUTH_DISABLED === "true";
|
|
433
|
+
function requireAuth(req, res, next) {
|
|
434
|
+
if (AUTH_DISABLED) {
|
|
435
|
+
req.user = { sub: "dev", username: "dev", role: "admin", iat: 0, exp: 0 };
|
|
436
|
+
return next();
|
|
437
|
+
}
|
|
438
|
+
const token = extractToken(req);
|
|
439
|
+
const payload = token ? verifyToken(token) : null;
|
|
440
|
+
if (!payload) {
|
|
441
|
+
res.status(401).json({ error: "Unauthorized" });
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
req.user = payload;
|
|
445
|
+
next();
|
|
446
|
+
}
|
|
447
|
+
function requireAdmin(req, res, next) {
|
|
448
|
+
const user = req.user;
|
|
449
|
+
if (!user || user.role !== "admin") {
|
|
450
|
+
res.status(403).json({ error: "Forbidden \u2014 admin only" });
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
next();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// cli/env-routes.ts
|
|
457
|
+
function createEnvRouter() {
|
|
458
|
+
const router = Router();
|
|
459
|
+
const ENV_FILE_PATH = join(process.cwd(), ".env");
|
|
460
|
+
function readEnvFile() {
|
|
461
|
+
if (!existsSync(ENV_FILE_PATH)) {
|
|
462
|
+
return {};
|
|
463
|
+
}
|
|
464
|
+
const content = readFileSync(ENV_FILE_PATH, "utf-8");
|
|
465
|
+
const env = {};
|
|
466
|
+
content.split("\n").forEach((line) => {
|
|
467
|
+
line = line.trim();
|
|
468
|
+
if (!line || line.startsWith("#")) return;
|
|
469
|
+
const match = line.match(/^([^=]+)=(.*)$/);
|
|
470
|
+
if (match) {
|
|
471
|
+
const [, key, value] = match;
|
|
472
|
+
env[key.trim()] = value.trim().replace(/^["']|["']$/g, "");
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
return env;
|
|
476
|
+
}
|
|
477
|
+
function writeEnvFile(env) {
|
|
478
|
+
const lines = [
|
|
479
|
+
"# OpenQA Environment Configuration",
|
|
480
|
+
"# Auto-generated by OpenQA Dashboard",
|
|
481
|
+
"# Last updated: " + (/* @__PURE__ */ new Date()).toISOString(),
|
|
482
|
+
""
|
|
483
|
+
];
|
|
484
|
+
const categories = {};
|
|
485
|
+
ENV_VARIABLES.forEach((v) => {
|
|
486
|
+
if (!categories[v.category]) {
|
|
487
|
+
categories[v.category] = [];
|
|
488
|
+
}
|
|
489
|
+
const value = env[v.key] || "";
|
|
490
|
+
if (value || v.required) {
|
|
491
|
+
categories[v.category].push(`${v.key}=${value}`);
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
const categoryNames = {
|
|
495
|
+
llm: "LLM CONFIGURATION",
|
|
496
|
+
security: "SECURITY",
|
|
497
|
+
target: "TARGET APPLICATION",
|
|
498
|
+
github: "GITHUB INTEGRATION",
|
|
499
|
+
web: "WEB SERVER",
|
|
500
|
+
agent: "AGENT CONFIGURATION",
|
|
501
|
+
database: "DATABASE",
|
|
502
|
+
notifications: "NOTIFICATIONS"
|
|
503
|
+
};
|
|
504
|
+
Object.entries(categories).forEach(([category, vars]) => {
|
|
505
|
+
if (vars.length > 0) {
|
|
506
|
+
lines.push("# " + "=".repeat(76));
|
|
507
|
+
lines.push(`# ${categoryNames[category] || category.toUpperCase()}`);
|
|
508
|
+
lines.push("# " + "=".repeat(76));
|
|
509
|
+
lines.push(...vars);
|
|
510
|
+
lines.push("");
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
writeFileSync(ENV_FILE_PATH, lines.join("\n"));
|
|
514
|
+
}
|
|
515
|
+
router.get("/api/env", requireAuth, requireAdmin, (_req, res) => {
|
|
516
|
+
try {
|
|
517
|
+
const envFile = readEnvFile();
|
|
518
|
+
const processEnv = process.env;
|
|
519
|
+
const variables = ENV_VARIABLES.map((v) => ({
|
|
520
|
+
key: v.key,
|
|
521
|
+
value: envFile[v.key] || processEnv[v.key] || "",
|
|
522
|
+
type: v.type,
|
|
523
|
+
category: v.category,
|
|
524
|
+
required: v.required,
|
|
525
|
+
description: v.description,
|
|
526
|
+
placeholder: v.placeholder,
|
|
527
|
+
options: v.options,
|
|
528
|
+
sensitive: v.sensitive,
|
|
529
|
+
testable: v.testable,
|
|
530
|
+
restartRequired: v.restartRequired,
|
|
531
|
+
// Mask sensitive values
|
|
532
|
+
displayValue: v.sensitive && (envFile[v.key] || processEnv[v.key]) ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : envFile[v.key] || processEnv[v.key] || ""
|
|
533
|
+
}));
|
|
534
|
+
res.json({
|
|
535
|
+
variables,
|
|
536
|
+
envFileExists: existsSync(ENV_FILE_PATH),
|
|
537
|
+
lastModified: existsSync(ENV_FILE_PATH) ? new Date(readFileSync(ENV_FILE_PATH, "utf-8").match(/Last updated: (.+)/)?.[1] || 0).toISOString() : null
|
|
538
|
+
});
|
|
539
|
+
} catch (error) {
|
|
540
|
+
res.status(500).json({
|
|
541
|
+
error: "Failed to read environment variables",
|
|
542
|
+
details: error instanceof Error ? error.message : String(error)
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
router.get("/api/env/:key", requireAuth, requireAdmin, (req, res) => {
|
|
547
|
+
try {
|
|
548
|
+
const { key } = req.params;
|
|
549
|
+
const envVar = ENV_VARIABLES.find((v) => v.key === key);
|
|
550
|
+
if (!envVar) {
|
|
551
|
+
res.status(404).json({ error: "Environment variable not found" });
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
const envFile = readEnvFile();
|
|
555
|
+
const value = envFile[key] || process.env[key] || "";
|
|
556
|
+
res.json({
|
|
557
|
+
...envVar,
|
|
558
|
+
value,
|
|
559
|
+
displayValue: envVar.sensitive && value ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : value
|
|
560
|
+
});
|
|
561
|
+
} catch (error) {
|
|
562
|
+
res.status(500).json({
|
|
563
|
+
error: "Failed to read environment variable",
|
|
564
|
+
details: error instanceof Error ? error.message : String(error)
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
router.put("/api/env/:key", requireAuth, requireAdmin, (req, res) => {
|
|
569
|
+
try {
|
|
570
|
+
const { key } = req.params;
|
|
571
|
+
const { value } = req.body;
|
|
572
|
+
const validation = validateEnvValue(key, value);
|
|
573
|
+
if (!validation.valid) {
|
|
574
|
+
res.status(400).json({ error: validation.error });
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
const env = readEnvFile();
|
|
578
|
+
if (value === "" || value === null || value === void 0) {
|
|
579
|
+
delete env[key];
|
|
580
|
+
} else {
|
|
581
|
+
env[key] = value;
|
|
582
|
+
}
|
|
583
|
+
writeEnvFile(env);
|
|
584
|
+
if (value) {
|
|
585
|
+
process.env[key] = value;
|
|
586
|
+
} else {
|
|
587
|
+
delete process.env[key];
|
|
588
|
+
}
|
|
589
|
+
const envVar = ENV_VARIABLES.find((v) => v.key === key);
|
|
590
|
+
res.json({
|
|
591
|
+
success: true,
|
|
592
|
+
key,
|
|
593
|
+
value: envVar?.sensitive ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : value,
|
|
594
|
+
restartRequired: envVar?.restartRequired || false
|
|
595
|
+
});
|
|
596
|
+
} catch (error) {
|
|
597
|
+
res.status(500).json({
|
|
598
|
+
error: "Failed to update environment variable",
|
|
599
|
+
details: error instanceof Error ? error.message : String(error)
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
router.post("/api/env/bulk", requireAuth, requireAdmin, (req, res) => {
|
|
604
|
+
try {
|
|
605
|
+
const { variables } = req.body;
|
|
606
|
+
if (!variables || typeof variables !== "object") {
|
|
607
|
+
res.status(400).json({ error: "Invalid request body" });
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
const errors = {};
|
|
611
|
+
Object.entries(variables).forEach(([key, value]) => {
|
|
612
|
+
const validation = validateEnvValue(key, value);
|
|
613
|
+
if (!validation.valid) {
|
|
614
|
+
errors[key] = validation.error || "Invalid value";
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
if (Object.keys(errors).length > 0) {
|
|
618
|
+
res.status(400).json({ error: "Validation failed", errors });
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
const env = readEnvFile();
|
|
622
|
+
Object.entries(variables).forEach(([key, value]) => {
|
|
623
|
+
if (value === "" || value === null || value === void 0) {
|
|
624
|
+
delete env[key];
|
|
625
|
+
delete process.env[key];
|
|
626
|
+
} else {
|
|
627
|
+
env[key] = value;
|
|
628
|
+
process.env[key] = value;
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
writeEnvFile(env);
|
|
632
|
+
const restartRequired = Object.keys(variables).some((key) => {
|
|
633
|
+
const envVar = ENV_VARIABLES.find((v) => v.key === key);
|
|
634
|
+
return envVar?.restartRequired;
|
|
635
|
+
});
|
|
636
|
+
res.json({
|
|
637
|
+
success: true,
|
|
638
|
+
updated: Object.keys(variables).length,
|
|
639
|
+
restartRequired
|
|
640
|
+
});
|
|
641
|
+
} catch (error) {
|
|
642
|
+
res.status(500).json({
|
|
643
|
+
error: "Failed to update environment variables",
|
|
644
|
+
details: error instanceof Error ? error.message : String(error)
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
router.post("/api/env/test/:key", requireAuth, requireAdmin, async (req, res) => {
|
|
649
|
+
try {
|
|
650
|
+
const { key } = req.params;
|
|
651
|
+
const { value } = req.body;
|
|
652
|
+
const envVar = ENV_VARIABLES.find((v) => v.key === key);
|
|
653
|
+
if (!envVar || !envVar.testable) {
|
|
654
|
+
res.status(400).json({ error: "This variable cannot be tested" });
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
let testResult;
|
|
658
|
+
switch (key) {
|
|
659
|
+
case "OPENAI_API_KEY":
|
|
660
|
+
testResult = await testOpenAIKey(value);
|
|
661
|
+
break;
|
|
662
|
+
case "ANTHROPIC_API_KEY":
|
|
663
|
+
testResult = await testAnthropicKey(value);
|
|
664
|
+
break;
|
|
665
|
+
case "OLLAMA_BASE_URL":
|
|
666
|
+
testResult = await testOllamaURL(value);
|
|
667
|
+
break;
|
|
668
|
+
case "GITHUB_TOKEN":
|
|
669
|
+
testResult = await testGitHubToken(value);
|
|
670
|
+
break;
|
|
671
|
+
case "SAAS_URL":
|
|
672
|
+
testResult = await testURL(value);
|
|
673
|
+
break;
|
|
674
|
+
case "SLACK_WEBHOOK_URL":
|
|
675
|
+
testResult = await testSlackWebhook(value);
|
|
676
|
+
break;
|
|
677
|
+
case "DISCORD_WEBHOOK_URL":
|
|
678
|
+
testResult = await testDiscordWebhook(value);
|
|
679
|
+
break;
|
|
680
|
+
default:
|
|
681
|
+
testResult = { success: false, message: "Test not implemented for this variable" };
|
|
682
|
+
}
|
|
683
|
+
res.json(testResult);
|
|
684
|
+
} catch (error) {
|
|
685
|
+
res.status(500).json({
|
|
686
|
+
success: false,
|
|
687
|
+
message: error instanceof Error ? error.message : "Test failed"
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
router.post("/api/env/generate/:key", requireAuth, requireAdmin, (req, res) => {
|
|
692
|
+
try {
|
|
693
|
+
const { key } = req.params;
|
|
694
|
+
let generated;
|
|
695
|
+
switch (key) {
|
|
696
|
+
case "OPENQA_JWT_SECRET":
|
|
697
|
+
generated = Array.from(
|
|
698
|
+
{ length: 64 },
|
|
699
|
+
() => Math.floor(Math.random() * 16).toString(16)
|
|
700
|
+
).join("");
|
|
701
|
+
break;
|
|
702
|
+
default:
|
|
703
|
+
res.status(400).json({ error: "Generation not supported for this variable" });
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
res.json({ success: true, value: generated });
|
|
707
|
+
} catch (error) {
|
|
708
|
+
res.status(500).json({
|
|
709
|
+
error: "Failed to generate value",
|
|
710
|
+
details: error instanceof Error ? error.message : String(error)
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
return router;
|
|
715
|
+
}
|
|
716
|
+
async function testOpenAIKey(apiKey) {
|
|
717
|
+
try {
|
|
718
|
+
const response = await fetch("https://api.openai.com/v1/models", {
|
|
719
|
+
headers: { "Authorization": `Bearer ${apiKey}` }
|
|
720
|
+
});
|
|
721
|
+
if (response.ok) {
|
|
722
|
+
return { success: true, message: "OpenAI API key is valid" };
|
|
723
|
+
}
|
|
724
|
+
return { success: false, message: "Invalid OpenAI API key" };
|
|
725
|
+
} catch {
|
|
726
|
+
return { success: false, message: "Failed to connect to OpenAI API" };
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
async function testAnthropicKey(apiKey) {
|
|
730
|
+
try {
|
|
731
|
+
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
732
|
+
method: "POST",
|
|
733
|
+
headers: {
|
|
734
|
+
"x-api-key": apiKey,
|
|
735
|
+
"anthropic-version": "2023-06-01",
|
|
736
|
+
"content-type": "application/json"
|
|
737
|
+
},
|
|
738
|
+
body: JSON.stringify({
|
|
739
|
+
model: "claude-3-haiku-20240307",
|
|
740
|
+
max_tokens: 1,
|
|
741
|
+
messages: [{ role: "user", content: "test" }]
|
|
742
|
+
})
|
|
743
|
+
});
|
|
744
|
+
if (response.status === 200 || response.status === 400) {
|
|
745
|
+
return { success: true, message: "Anthropic API key is valid" };
|
|
746
|
+
}
|
|
747
|
+
return { success: false, message: "Invalid Anthropic API key" };
|
|
748
|
+
} catch {
|
|
749
|
+
return { success: false, message: "Failed to connect to Anthropic API" };
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
async function testOllamaURL(url) {
|
|
753
|
+
try {
|
|
754
|
+
const response = await fetch(`${url}/api/tags`);
|
|
755
|
+
if (response.ok) {
|
|
756
|
+
return { success: true, message: "Ollama server is accessible" };
|
|
757
|
+
}
|
|
758
|
+
return { success: false, message: "Ollama server returned an error" };
|
|
759
|
+
} catch {
|
|
760
|
+
return { success: false, message: "Cannot connect to Ollama server" };
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
async function testGitHubToken(token) {
|
|
764
|
+
try {
|
|
765
|
+
const response = await fetch("https://api.github.com/user", {
|
|
766
|
+
headers: { "Authorization": `token ${token}` }
|
|
767
|
+
});
|
|
768
|
+
if (response.ok) {
|
|
769
|
+
const data = await response.json();
|
|
770
|
+
return { success: true, message: `GitHub token is valid (user: ${data.login})` };
|
|
771
|
+
}
|
|
772
|
+
return { success: false, message: "Invalid GitHub token" };
|
|
773
|
+
} catch {
|
|
774
|
+
return { success: false, message: "Failed to connect to GitHub API" };
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
async function testURL(url) {
|
|
778
|
+
try {
|
|
779
|
+
const response = await fetch(url, { method: "HEAD" });
|
|
780
|
+
if (response.ok) {
|
|
781
|
+
return { success: true, message: "URL is accessible" };
|
|
782
|
+
}
|
|
783
|
+
return { success: false, message: `URL returned status ${response.status}` };
|
|
784
|
+
} catch {
|
|
785
|
+
return { success: false, message: "Cannot connect to URL" };
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
async function testSlackWebhook(url) {
|
|
789
|
+
try {
|
|
790
|
+
const response = await fetch(url, {
|
|
791
|
+
method: "POST",
|
|
792
|
+
headers: { "Content-Type": "application/json" },
|
|
793
|
+
body: JSON.stringify({ text: "OpenQA webhook test" })
|
|
794
|
+
});
|
|
795
|
+
if (response.ok) {
|
|
796
|
+
return { success: true, message: "Slack webhook is valid" };
|
|
797
|
+
}
|
|
798
|
+
return { success: false, message: "Invalid Slack webhook" };
|
|
799
|
+
} catch {
|
|
800
|
+
return { success: false, message: "Failed to connect to Slack webhook" };
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
async function testDiscordWebhook(url) {
|
|
804
|
+
try {
|
|
805
|
+
const response = await fetch(url, {
|
|
806
|
+
method: "POST",
|
|
807
|
+
headers: { "Content-Type": "application/json" },
|
|
808
|
+
body: JSON.stringify({ content: "OpenQA webhook test" })
|
|
809
|
+
});
|
|
810
|
+
if (response.ok || response.status === 204) {
|
|
811
|
+
return { success: true, message: "Discord webhook is valid" };
|
|
812
|
+
}
|
|
813
|
+
return { success: false, message: "Invalid Discord webhook" };
|
|
814
|
+
} catch {
|
|
815
|
+
return { success: false, message: "Failed to connect to Discord webhook" };
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
export {
|
|
819
|
+
createEnvRouter
|
|
820
|
+
};
|