@openqa/cli 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1468,6 +1468,1049 @@ function createApiRouter(db, config) {
1468
1468
  return router;
1469
1469
  }
1470
1470
 
1471
+ // cli/auth/router.ts
1472
+ init_esm_shims();
1473
+ import { Router as Router2 } from "express";
1474
+ import { z as z3 } from "zod";
1475
+
1476
+ // cli/auth/passwords.ts
1477
+ init_esm_shims();
1478
+ import { scrypt, randomBytes, timingSafeEqual } from "crypto";
1479
+ import { promisify } from "util";
1480
+ var scryptAsync = promisify(scrypt);
1481
+ var KEYLEN = 64;
1482
+ async function hashPassword(plain) {
1483
+ const salt = randomBytes(16).toString("hex");
1484
+ const hash = await scryptAsync(plain, salt, KEYLEN);
1485
+ return `$scrypt$${salt}$${hash.toString("hex")}`;
1486
+ }
1487
+ async function verifyPassword(plain, stored) {
1488
+ const parts = stored.split("$");
1489
+ if (parts.length !== 4 || parts[1] !== "scrypt") return false;
1490
+ const [, , salt, hashHex] = parts;
1491
+ const storedHash = Buffer.from(hashHex, "hex");
1492
+ const derived = await scryptAsync(plain, salt, KEYLEN);
1493
+ if (derived.length !== storedHash.length) return false;
1494
+ return timingSafeEqual(derived, storedHash);
1495
+ }
1496
+
1497
+ // cli/auth/jwt.ts
1498
+ init_esm_shims();
1499
+ import { createHmac, randomBytes as randomBytes2 } from "crypto";
1500
+ import { parse as parseCookies } from "cookie";
1501
+ var TTL_MS = 24 * 60 * 60 * 1e3;
1502
+ var COOKIE_NAME = "openqa_token";
1503
+ var _secret = null;
1504
+ function getSecret() {
1505
+ if (_secret) return _secret;
1506
+ _secret = process.env.OPENQA_JWT_SECRET ?? null;
1507
+ if (!_secret) {
1508
+ _secret = randomBytes2(32).toString("hex");
1509
+ console.warn("[OpenQA] OPENQA_JWT_SECRET not set \u2014 using a volatile secret. All sessions will be invalidated on restart.");
1510
+ }
1511
+ return _secret;
1512
+ }
1513
+ function b64url(input) {
1514
+ return Buffer.from(input).toString("base64url");
1515
+ }
1516
+ function fromB64url(input) {
1517
+ return Buffer.from(input, "base64url").toString("utf8");
1518
+ }
1519
+ function signToken(payload) {
1520
+ const now = Math.floor(Date.now() / 1e3);
1521
+ const full = { ...payload, iat: now, exp: now + Math.floor(TTL_MS / 1e3) };
1522
+ const header = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
1523
+ const body = b64url(JSON.stringify(full));
1524
+ const sig = createHmac("sha256", getSecret()).update(`${header}.${body}`).digest("base64url");
1525
+ return `${header}.${body}.${sig}`;
1526
+ }
1527
+ function verifyToken(token) {
1528
+ try {
1529
+ const [header, body, sig] = token.split(".");
1530
+ if (!header || !body || !sig) return null;
1531
+ const expected = createHmac("sha256", getSecret()).update(`${header}.${body}`).digest("base64url");
1532
+ if (expected !== sig) return null;
1533
+ const payload = JSON.parse(fromB64url(body));
1534
+ if (payload.exp < Math.floor(Date.now() / 1e3)) return null;
1535
+ return payload;
1536
+ } catch {
1537
+ return null;
1538
+ }
1539
+ }
1540
+ function setAuthCookie(res, token) {
1541
+ res.cookie(COOKIE_NAME, token, {
1542
+ httpOnly: true,
1543
+ sameSite: "lax",
1544
+ secure: process.env.NODE_ENV === "production",
1545
+ maxAge: TTL_MS,
1546
+ path: "/"
1547
+ });
1548
+ }
1549
+ function clearAuthCookie(res) {
1550
+ res.clearCookie(COOKIE_NAME, { path: "/" });
1551
+ }
1552
+ function extractToken(req) {
1553
+ const cookieHeader = req.headers.cookie ?? "";
1554
+ if (cookieHeader) {
1555
+ const cookies = parseCookies(cookieHeader);
1556
+ if (cookies[COOKIE_NAME]) return cookies[COOKIE_NAME];
1557
+ }
1558
+ const auth = req.headers.authorization ?? "";
1559
+ if (auth.startsWith("Bearer ")) return auth.slice(7);
1560
+ return null;
1561
+ }
1562
+
1563
+ // cli/auth/middleware.ts
1564
+ init_esm_shims();
1565
+ var AUTH_DISABLED = process.env.OPENQA_AUTH_DISABLED === "true";
1566
+ function requireAuth(req, res, next) {
1567
+ if (AUTH_DISABLED) {
1568
+ req.user = { sub: "dev", username: "dev", role: "admin", iat: 0, exp: 0 };
1569
+ return next();
1570
+ }
1571
+ const token = extractToken(req);
1572
+ const payload = token ? verifyToken(token) : null;
1573
+ if (!payload) {
1574
+ res.status(401).json({ error: "Unauthorized" });
1575
+ return;
1576
+ }
1577
+ req.user = payload;
1578
+ next();
1579
+ }
1580
+ function requireAdmin(req, res, next) {
1581
+ const user = req.user;
1582
+ if (!user || user.role !== "admin") {
1583
+ res.status(403).json({ error: "Forbidden \u2014 admin only" });
1584
+ return;
1585
+ }
1586
+ next();
1587
+ }
1588
+ function authOrRedirect(db) {
1589
+ return async (req, res, next) => {
1590
+ if (AUTH_DISABLED) return next();
1591
+ const token = extractToken(req);
1592
+ const payload = token ? verifyToken(token) : null;
1593
+ if (payload) {
1594
+ req.user = payload;
1595
+ return next();
1596
+ }
1597
+ const count = await db.countUsers();
1598
+ if (count === 0) {
1599
+ res.redirect("/setup");
1600
+ } else {
1601
+ res.redirect("/login");
1602
+ }
1603
+ };
1604
+ }
1605
+
1606
+ // cli/auth/router.ts
1607
+ function validate2(schema) {
1608
+ return (req, res, next) => {
1609
+ const r = schema.safeParse(req.body);
1610
+ if (!r.success) {
1611
+ res.status(400).json({ error: r.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", ") });
1612
+ return;
1613
+ }
1614
+ req.body = r.data;
1615
+ next();
1616
+ };
1617
+ }
1618
+ var loginSchema = z3.object({
1619
+ username: z3.string().min(1).max(100),
1620
+ password: z3.string().min(1).max(200)
1621
+ });
1622
+ var setupSchema = z3.object({
1623
+ username: z3.string().min(3).max(100).regex(/^[a-z0-9_.@-]+$/, "Only lowercase letters, digits, and ._@- characters"),
1624
+ password: z3.string().min(8).max(200)
1625
+ });
1626
+ var changePasswordSchema = z3.object({
1627
+ currentPassword: z3.string().min(1),
1628
+ newPassword: z3.string().min(8).max(200)
1629
+ });
1630
+ var createUserSchema = z3.object({
1631
+ username: z3.string().min(3).max(100).regex(/^[a-z0-9_.@-]+$/, "Only lowercase letters, digits, and ._@- characters"),
1632
+ password: z3.string().min(8).max(200),
1633
+ role: z3.enum(["admin", "viewer"])
1634
+ });
1635
+ var updateUserSchema = z3.object({
1636
+ role: z3.enum(["admin", "viewer"]).optional(),
1637
+ password: z3.string().min(8).max(200).optional()
1638
+ });
1639
+ function createAuthRouter(db) {
1640
+ const router = Router2();
1641
+ router.post("/api/auth/login", validate2(loginSchema), async (req, res) => {
1642
+ const { username, password } = req.body;
1643
+ const user = await db.findUserByUsername(username);
1644
+ if (!user) {
1645
+ res.status(401).json({ error: "Invalid credentials" });
1646
+ return;
1647
+ }
1648
+ const ok = await verifyPassword(password, user.passwordHash);
1649
+ if (!ok) {
1650
+ res.status(401).json({ error: "Invalid credentials" });
1651
+ return;
1652
+ }
1653
+ const token = signToken({ sub: user.id, username: user.username, role: user.role });
1654
+ setAuthCookie(res, token);
1655
+ res.json({ token, user: { id: user.id, username: user.username, role: user.role } });
1656
+ });
1657
+ router.post("/api/auth/logout", (_req, res) => {
1658
+ clearAuthCookie(res);
1659
+ res.json({ success: true });
1660
+ });
1661
+ router.get("/api/auth/me", requireAuth, (req, res) => {
1662
+ const { sub, username, role } = req.user;
1663
+ res.json({ id: sub, username, role });
1664
+ });
1665
+ router.post("/api/auth/change-password", requireAuth, validate2(changePasswordSchema), async (req, res) => {
1666
+ const { currentPassword, newPassword } = req.body;
1667
+ const userId = req.user.sub;
1668
+ const user = await db.getUserById(userId);
1669
+ if (!user) {
1670
+ res.status(404).json({ error: "User not found" });
1671
+ return;
1672
+ }
1673
+ const ok = await verifyPassword(currentPassword, user.passwordHash);
1674
+ if (!ok) {
1675
+ res.status(401).json({ error: "Current password is incorrect" });
1676
+ return;
1677
+ }
1678
+ const passwordHash = await hashPassword(newPassword);
1679
+ await db.updateUser(userId, { passwordHash });
1680
+ res.json({ success: true });
1681
+ });
1682
+ router.post("/api/setup", validate2(setupSchema), async (req, res) => {
1683
+ const count = await db.countUsers();
1684
+ if (count > 0) {
1685
+ res.status(409).json({ error: "Setup already complete" });
1686
+ return;
1687
+ }
1688
+ const { username, password } = req.body;
1689
+ const existing = await db.findUserByUsername(username);
1690
+ if (existing) {
1691
+ res.status(409).json({ error: "Username already taken" });
1692
+ return;
1693
+ }
1694
+ const passwordHash = await hashPassword(password);
1695
+ const user = await db.createUser({ username, passwordHash, role: "admin" });
1696
+ const token = signToken({ sub: user.id, username: user.username, role: user.role });
1697
+ setAuthCookie(res, token);
1698
+ res.json({ token, user: { id: user.id, username: user.username, role: user.role } });
1699
+ });
1700
+ router.get("/api/accounts", requireAuth, requireAdmin, async (_req, res) => {
1701
+ const users = await db.getAllUsers();
1702
+ res.json(users.map((u) => ({ id: u.id, username: u.username, role: u.role, createdAt: u.createdAt })));
1703
+ });
1704
+ router.post("/api/accounts", requireAuth, requireAdmin, validate2(createUserSchema), async (req, res) => {
1705
+ const { username, password, role } = req.body;
1706
+ const existing = await db.findUserByUsername(username);
1707
+ if (existing) {
1708
+ res.status(409).json({ error: "Username already taken" });
1709
+ return;
1710
+ }
1711
+ const passwordHash = await hashPassword(password);
1712
+ const user = await db.createUser({ username, passwordHash, role });
1713
+ res.status(201).json({ id: user.id, username: user.username, role: user.role, createdAt: user.createdAt });
1714
+ });
1715
+ router.put("/api/accounts/:id", requireAuth, requireAdmin, validate2(updateUserSchema), async (req, res) => {
1716
+ const { id } = req.params;
1717
+ const user = await db.getUserById(id);
1718
+ if (!user) {
1719
+ res.status(404).json({ error: "User not found" });
1720
+ return;
1721
+ }
1722
+ const updates = {};
1723
+ if (req.body.role) updates.role = req.body.role;
1724
+ if (req.body.password) updates.passwordHash = await hashPassword(req.body.password);
1725
+ if (updates.role === "viewer" && user.role === "admin") {
1726
+ const allAdmins = (await db.getAllUsers()).filter((u) => u.role === "admin");
1727
+ if (allAdmins.length <= 1) {
1728
+ res.status(400).json({ error: "Cannot demote the last admin" });
1729
+ return;
1730
+ }
1731
+ }
1732
+ await db.updateUser(id, updates);
1733
+ const updated = await db.getUserById(id);
1734
+ res.json({ id: updated.id, username: updated.username, role: updated.role });
1735
+ });
1736
+ router.delete("/api/accounts/:id", requireAuth, requireAdmin, async (req, res) => {
1737
+ const { id } = req.params;
1738
+ const actor = req.user;
1739
+ if (actor.sub === id) {
1740
+ res.status(400).json({ error: "Cannot delete your own account" });
1741
+ return;
1742
+ }
1743
+ const user = await db.getUserById(id);
1744
+ if (!user) {
1745
+ res.status(404).json({ error: "User not found" });
1746
+ return;
1747
+ }
1748
+ if (user.role === "admin") {
1749
+ const allAdmins = (await db.getAllUsers()).filter((u) => u.role === "admin");
1750
+ if (allAdmins.length <= 1) {
1751
+ res.status(400).json({ error: "Cannot delete the last admin" });
1752
+ return;
1753
+ }
1754
+ }
1755
+ await db.deleteUser(id);
1756
+ res.json({ success: true });
1757
+ });
1758
+ return router;
1759
+ }
1760
+
1761
+ // cli/env-routes.ts
1762
+ init_esm_shims();
1763
+ import { Router as Router3 } from "express";
1764
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync } from "fs";
1765
+ import { join as join2 } from "path";
1766
+
1767
+ // cli/env-config.ts
1768
+ init_esm_shims();
1769
+ var ENV_VARIABLES = [
1770
+ // ============================================================================
1771
+ // LLM CONFIGURATION
1772
+ // ============================================================================
1773
+ {
1774
+ key: "LLM_PROVIDER",
1775
+ type: "select",
1776
+ category: "llm",
1777
+ required: true,
1778
+ description: "LLM provider to use for AI operations",
1779
+ options: ["openai", "anthropic", "ollama"],
1780
+ placeholder: "openai",
1781
+ restartRequired: true
1782
+ },
1783
+ {
1784
+ key: "OPENAI_API_KEY",
1785
+ type: "password",
1786
+ category: "llm",
1787
+ required: false,
1788
+ description: "OpenAI API key (required if LLM_PROVIDER=openai)",
1789
+ placeholder: "sk-...",
1790
+ sensitive: true,
1791
+ testable: true,
1792
+ validation: (value) => {
1793
+ if (!value) return { valid: true };
1794
+ if (!value.startsWith("sk-")) {
1795
+ return { valid: false, error: 'OpenAI API key must start with "sk-"' };
1796
+ }
1797
+ if (value.length < 20) {
1798
+ return { valid: false, error: "API key seems too short" };
1799
+ }
1800
+ return { valid: true };
1801
+ },
1802
+ restartRequired: true
1803
+ },
1804
+ {
1805
+ key: "ANTHROPIC_API_KEY",
1806
+ type: "password",
1807
+ category: "llm",
1808
+ required: false,
1809
+ description: "Anthropic API key (required if LLM_PROVIDER=anthropic)",
1810
+ placeholder: "sk-ant-...",
1811
+ sensitive: true,
1812
+ testable: true,
1813
+ validation: (value) => {
1814
+ if (!value) return { valid: true };
1815
+ if (!value.startsWith("sk-ant-")) {
1816
+ return { valid: false, error: 'Anthropic API key must start with "sk-ant-"' };
1817
+ }
1818
+ return { valid: true };
1819
+ },
1820
+ restartRequired: true
1821
+ },
1822
+ {
1823
+ key: "OLLAMA_BASE_URL",
1824
+ type: "url",
1825
+ category: "llm",
1826
+ required: false,
1827
+ description: "Ollama server URL (required if LLM_PROVIDER=ollama)",
1828
+ placeholder: "http://localhost:11434",
1829
+ testable: true,
1830
+ validation: (value) => {
1831
+ if (!value) return { valid: true };
1832
+ try {
1833
+ new URL(value);
1834
+ return { valid: true };
1835
+ } catch {
1836
+ return { valid: false, error: "Invalid URL format" };
1837
+ }
1838
+ },
1839
+ restartRequired: true
1840
+ },
1841
+ {
1842
+ key: "LLM_MODEL",
1843
+ type: "text",
1844
+ category: "llm",
1845
+ required: false,
1846
+ description: "LLM model to use (e.g., gpt-4, claude-3-opus, llama2)",
1847
+ placeholder: "gpt-4",
1848
+ restartRequired: true
1849
+ },
1850
+ // ============================================================================
1851
+ // SECURITY
1852
+ // ============================================================================
1853
+ {
1854
+ key: "OPENQA_JWT_SECRET",
1855
+ type: "password",
1856
+ category: "security",
1857
+ required: true,
1858
+ description: "Secret key for JWT token signing (min 32 characters)",
1859
+ placeholder: "Generate with: openssl rand -hex 32",
1860
+ sensitive: true,
1861
+ validation: (value) => {
1862
+ if (!value) return { valid: false, error: "JWT secret is required" };
1863
+ if (value.length < 32) {
1864
+ return { valid: false, error: "JWT secret must be at least 32 characters" };
1865
+ }
1866
+ return { valid: true };
1867
+ },
1868
+ restartRequired: true
1869
+ },
1870
+ {
1871
+ key: "OPENQA_AUTH_DISABLED",
1872
+ type: "boolean",
1873
+ category: "security",
1874
+ required: false,
1875
+ description: "\u26A0\uFE0F DANGER: Disable authentication (NEVER use in production!)",
1876
+ placeholder: "false",
1877
+ validation: (value) => {
1878
+ if (value === "true" && process.env.NODE_ENV === "production") {
1879
+ return { valid: false, error: "Cannot disable auth in production!" };
1880
+ }
1881
+ return { valid: true };
1882
+ },
1883
+ restartRequired: true
1884
+ },
1885
+ {
1886
+ key: "NODE_ENV",
1887
+ type: "select",
1888
+ category: "security",
1889
+ required: false,
1890
+ description: "Node environment (production enables security features)",
1891
+ options: ["development", "production", "test"],
1892
+ placeholder: "production",
1893
+ restartRequired: true
1894
+ },
1895
+ // ============================================================================
1896
+ // TARGET APPLICATION
1897
+ // ============================================================================
1898
+ {
1899
+ key: "SAAS_URL",
1900
+ type: "url",
1901
+ category: "target",
1902
+ required: true,
1903
+ description: "URL of the application to test",
1904
+ placeholder: "https://your-app.com",
1905
+ testable: true,
1906
+ validation: (value) => {
1907
+ if (!value) return { valid: false, error: "Target URL is required" };
1908
+ try {
1909
+ const url = new URL(value);
1910
+ if (!["http:", "https:"].includes(url.protocol)) {
1911
+ return { valid: false, error: "URL must use http or https protocol" };
1912
+ }
1913
+ return { valid: true };
1914
+ } catch {
1915
+ return { valid: false, error: "Invalid URL format" };
1916
+ }
1917
+ }
1918
+ },
1919
+ {
1920
+ key: "SAAS_AUTH_TYPE",
1921
+ type: "select",
1922
+ category: "target",
1923
+ required: false,
1924
+ description: "Authentication type for target application",
1925
+ options: ["none", "basic", "session"],
1926
+ placeholder: "none"
1927
+ },
1928
+ {
1929
+ key: "SAAS_USERNAME",
1930
+ type: "text",
1931
+ category: "target",
1932
+ required: false,
1933
+ description: "Username for target application authentication",
1934
+ placeholder: "test@example.com"
1935
+ },
1936
+ {
1937
+ key: "SAAS_PASSWORD",
1938
+ type: "password",
1939
+ category: "target",
1940
+ required: false,
1941
+ description: "Password for target application authentication",
1942
+ placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",
1943
+ sensitive: true
1944
+ },
1945
+ // ============================================================================
1946
+ // GITHUB INTEGRATION
1947
+ // ============================================================================
1948
+ {
1949
+ key: "GITHUB_TOKEN",
1950
+ type: "password",
1951
+ category: "github",
1952
+ required: false,
1953
+ description: "GitHub personal access token for issue creation",
1954
+ placeholder: "ghp_...",
1955
+ sensitive: true,
1956
+ testable: true,
1957
+ validation: (value) => {
1958
+ if (!value) return { valid: true };
1959
+ if (!value.startsWith("ghp_") && !value.startsWith("github_pat_")) {
1960
+ return { valid: false, error: 'GitHub token must start with "ghp_" or "github_pat_"' };
1961
+ }
1962
+ return { valid: true };
1963
+ }
1964
+ },
1965
+ {
1966
+ key: "GITHUB_OWNER",
1967
+ type: "text",
1968
+ category: "github",
1969
+ required: false,
1970
+ description: "GitHub repository owner/organization",
1971
+ placeholder: "your-username"
1972
+ },
1973
+ {
1974
+ key: "GITHUB_REPO",
1975
+ type: "text",
1976
+ category: "github",
1977
+ required: false,
1978
+ description: "GitHub repository name",
1979
+ placeholder: "your-repo"
1980
+ },
1981
+ {
1982
+ key: "GITHUB_BRANCH",
1983
+ type: "text",
1984
+ category: "github",
1985
+ required: false,
1986
+ description: "GitHub branch to monitor",
1987
+ placeholder: "main"
1988
+ },
1989
+ // ============================================================================
1990
+ // WEB SERVER
1991
+ // ============================================================================
1992
+ {
1993
+ key: "WEB_PORT",
1994
+ type: "number",
1995
+ category: "web",
1996
+ required: false,
1997
+ description: "Port for web server",
1998
+ placeholder: "4242",
1999
+ validation: (value) => {
2000
+ if (!value) return { valid: true };
2001
+ const port = parseInt(value, 10);
2002
+ if (isNaN(port) || port < 1 || port > 65535) {
2003
+ return { valid: false, error: "Port must be between 1 and 65535" };
2004
+ }
2005
+ return { valid: true };
2006
+ },
2007
+ restartRequired: true
2008
+ },
2009
+ {
2010
+ key: "WEB_HOST",
2011
+ type: "text",
2012
+ category: "web",
2013
+ required: false,
2014
+ description: "Host to bind web server (0.0.0.0 for all interfaces)",
2015
+ placeholder: "0.0.0.0",
2016
+ restartRequired: true
2017
+ },
2018
+ {
2019
+ key: "CORS_ORIGINS",
2020
+ type: "text",
2021
+ category: "web",
2022
+ required: false,
2023
+ description: "Allowed CORS origins (comma-separated)",
2024
+ placeholder: "https://your-domain.com,https://app.example.com",
2025
+ restartRequired: true
2026
+ },
2027
+ // ============================================================================
2028
+ // AGENT CONFIGURATION
2029
+ // ============================================================================
2030
+ {
2031
+ key: "AGENT_AUTO_START",
2032
+ type: "boolean",
2033
+ category: "agent",
2034
+ required: false,
2035
+ description: "Auto-start agent on server launch",
2036
+ placeholder: "false"
2037
+ },
2038
+ {
2039
+ key: "AGENT_INTERVAL_MS",
2040
+ type: "number",
2041
+ category: "agent",
2042
+ required: false,
2043
+ description: "Agent run interval in milliseconds (1 hour = 3600000)",
2044
+ placeholder: "3600000",
2045
+ validation: (value) => {
2046
+ if (!value) return { valid: true };
2047
+ const interval = parseInt(value, 10);
2048
+ if (isNaN(interval) || interval < 6e4) {
2049
+ return { valid: false, error: "Interval must be at least 60000ms (1 minute)" };
2050
+ }
2051
+ return { valid: true };
2052
+ }
2053
+ },
2054
+ {
2055
+ key: "AGENT_MAX_ITERATIONS",
2056
+ type: "number",
2057
+ category: "agent",
2058
+ required: false,
2059
+ description: "Maximum iterations per agent session",
2060
+ placeholder: "20",
2061
+ validation: (value) => {
2062
+ if (!value) return { valid: true };
2063
+ const max = parseInt(value, 10);
2064
+ if (isNaN(max) || max < 1 || max > 1e3) {
2065
+ return { valid: false, error: "Max iterations must be between 1 and 1000" };
2066
+ }
2067
+ return { valid: true };
2068
+ }
2069
+ },
2070
+ {
2071
+ key: "GIT_LISTENER_ENABLED",
2072
+ type: "boolean",
2073
+ category: "agent",
2074
+ required: false,
2075
+ description: "Enable git merge/pipeline detection",
2076
+ placeholder: "true"
2077
+ },
2078
+ {
2079
+ key: "GIT_POLL_INTERVAL_MS",
2080
+ type: "number",
2081
+ category: "agent",
2082
+ required: false,
2083
+ description: "Git polling interval in milliseconds",
2084
+ placeholder: "60000"
2085
+ },
2086
+ // ============================================================================
2087
+ // DATABASE
2088
+ // ============================================================================
2089
+ {
2090
+ key: "DB_PATH",
2091
+ type: "text",
2092
+ category: "database",
2093
+ required: false,
2094
+ description: "Path to SQLite database file",
2095
+ placeholder: "./data/openqa.db",
2096
+ restartRequired: true
2097
+ },
2098
+ // ============================================================================
2099
+ // NOTIFICATIONS
2100
+ // ============================================================================
2101
+ {
2102
+ key: "SLACK_WEBHOOK_URL",
2103
+ type: "url",
2104
+ category: "notifications",
2105
+ required: false,
2106
+ description: "Slack webhook URL for notifications",
2107
+ placeholder: "https://hooks.slack.com/services/...",
2108
+ sensitive: true,
2109
+ testable: true,
2110
+ validation: (value) => {
2111
+ if (!value) return { valid: true };
2112
+ if (!value.startsWith("https://hooks.slack.com/")) {
2113
+ return { valid: false, error: "Invalid Slack webhook URL" };
2114
+ }
2115
+ return { valid: true };
2116
+ }
2117
+ },
2118
+ {
2119
+ key: "DISCORD_WEBHOOK_URL",
2120
+ type: "url",
2121
+ category: "notifications",
2122
+ required: false,
2123
+ description: "Discord webhook URL for notifications",
2124
+ placeholder: "https://discord.com/api/webhooks/...",
2125
+ sensitive: true,
2126
+ testable: true,
2127
+ validation: (value) => {
2128
+ if (!value) return { valid: true };
2129
+ if (!value.startsWith("https://discord.com/api/webhooks/")) {
2130
+ return { valid: false, error: "Invalid Discord webhook URL" };
2131
+ }
2132
+ return { valid: true };
2133
+ }
2134
+ }
2135
+ ];
2136
+ function getEnvVariable(key) {
2137
+ return ENV_VARIABLES.find((v) => v.key === key);
2138
+ }
2139
+ function validateEnvValue(key, value) {
2140
+ const envVar = getEnvVariable(key);
2141
+ if (!envVar) return { valid: false, error: "Unknown environment variable" };
2142
+ if (envVar.required && !value) {
2143
+ return { valid: false, error: "This field is required" };
2144
+ }
2145
+ if (envVar.validation) {
2146
+ return envVar.validation(value);
2147
+ }
2148
+ return { valid: true };
2149
+ }
2150
+
2151
+ // cli/env-routes.ts
2152
+ function createEnvRouter() {
2153
+ const router = Router3();
2154
+ const ENV_FILE_PATH = join2(process.cwd(), ".env");
2155
+ function readEnvFile() {
2156
+ if (!existsSync(ENV_FILE_PATH)) {
2157
+ return {};
2158
+ }
2159
+ const content = readFileSync2(ENV_FILE_PATH, "utf-8");
2160
+ const env = {};
2161
+ content.split("\n").forEach((line) => {
2162
+ line = line.trim();
2163
+ if (!line || line.startsWith("#")) return;
2164
+ const match = line.match(/^([^=]+)=(.*)$/);
2165
+ if (match) {
2166
+ const [, key, value] = match;
2167
+ env[key.trim()] = value.trim().replace(/^["']|["']$/g, "");
2168
+ }
2169
+ });
2170
+ return env;
2171
+ }
2172
+ function writeEnvFile(env) {
2173
+ const lines = [
2174
+ "# OpenQA Environment Configuration",
2175
+ "# Auto-generated by OpenQA Dashboard",
2176
+ "# Last updated: " + (/* @__PURE__ */ new Date()).toISOString(),
2177
+ ""
2178
+ ];
2179
+ const categories = {};
2180
+ ENV_VARIABLES.forEach((v) => {
2181
+ if (!categories[v.category]) {
2182
+ categories[v.category] = [];
2183
+ }
2184
+ const value = env[v.key] || "";
2185
+ if (value || v.required) {
2186
+ categories[v.category].push(`${v.key}=${value}`);
2187
+ }
2188
+ });
2189
+ const categoryNames = {
2190
+ llm: "LLM CONFIGURATION",
2191
+ security: "SECURITY",
2192
+ target: "TARGET APPLICATION",
2193
+ github: "GITHUB INTEGRATION",
2194
+ web: "WEB SERVER",
2195
+ agent: "AGENT CONFIGURATION",
2196
+ database: "DATABASE",
2197
+ notifications: "NOTIFICATIONS"
2198
+ };
2199
+ Object.entries(categories).forEach(([category, vars]) => {
2200
+ if (vars.length > 0) {
2201
+ lines.push("# " + "=".repeat(76));
2202
+ lines.push(`# ${categoryNames[category] || category.toUpperCase()}`);
2203
+ lines.push("# " + "=".repeat(76));
2204
+ lines.push(...vars);
2205
+ lines.push("");
2206
+ }
2207
+ });
2208
+ writeFileSync2(ENV_FILE_PATH, lines.join("\n"));
2209
+ }
2210
+ router.get("/api/env", requireAuth, requireAdmin, (_req, res) => {
2211
+ try {
2212
+ const envFile = readEnvFile();
2213
+ const processEnv = process.env;
2214
+ const variables = ENV_VARIABLES.map((v) => ({
2215
+ key: v.key,
2216
+ value: envFile[v.key] || processEnv[v.key] || "",
2217
+ type: v.type,
2218
+ category: v.category,
2219
+ required: v.required,
2220
+ description: v.description,
2221
+ placeholder: v.placeholder,
2222
+ options: v.options,
2223
+ sensitive: v.sensitive,
2224
+ testable: v.testable,
2225
+ restartRequired: v.restartRequired,
2226
+ // Mask sensitive values
2227
+ displayValue: v.sensitive && (envFile[v.key] || processEnv[v.key]) ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : envFile[v.key] || processEnv[v.key] || ""
2228
+ }));
2229
+ res.json({
2230
+ variables,
2231
+ envFileExists: existsSync(ENV_FILE_PATH),
2232
+ lastModified: existsSync(ENV_FILE_PATH) ? new Date(readFileSync2(ENV_FILE_PATH, "utf-8").match(/Last updated: (.+)/)?.[1] || 0).toISOString() : null
2233
+ });
2234
+ } catch (error) {
2235
+ res.status(500).json({
2236
+ error: "Failed to read environment variables",
2237
+ details: error instanceof Error ? error.message : String(error)
2238
+ });
2239
+ }
2240
+ });
2241
+ router.get("/api/env/:key", requireAuth, requireAdmin, (req, res) => {
2242
+ try {
2243
+ const { key } = req.params;
2244
+ const envVar = ENV_VARIABLES.find((v) => v.key === key);
2245
+ if (!envVar) {
2246
+ res.status(404).json({ error: "Environment variable not found" });
2247
+ return;
2248
+ }
2249
+ const envFile = readEnvFile();
2250
+ const value = envFile[key] || process.env[key] || "";
2251
+ res.json({
2252
+ ...envVar,
2253
+ value,
2254
+ displayValue: envVar.sensitive && value ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : value
2255
+ });
2256
+ } catch (error) {
2257
+ res.status(500).json({
2258
+ error: "Failed to read environment variable",
2259
+ details: error instanceof Error ? error.message : String(error)
2260
+ });
2261
+ }
2262
+ });
2263
+ router.put("/api/env/:key", requireAuth, requireAdmin, (req, res) => {
2264
+ try {
2265
+ const { key } = req.params;
2266
+ const { value } = req.body;
2267
+ const validation = validateEnvValue(key, value);
2268
+ if (!validation.valid) {
2269
+ res.status(400).json({ error: validation.error });
2270
+ return;
2271
+ }
2272
+ const env = readEnvFile();
2273
+ if (value === "" || value === null || value === void 0) {
2274
+ delete env[key];
2275
+ } else {
2276
+ env[key] = value;
2277
+ }
2278
+ writeEnvFile(env);
2279
+ if (value) {
2280
+ process.env[key] = value;
2281
+ } else {
2282
+ delete process.env[key];
2283
+ }
2284
+ const envVar = ENV_VARIABLES.find((v) => v.key === key);
2285
+ res.json({
2286
+ success: true,
2287
+ key,
2288
+ value: envVar?.sensitive ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : value,
2289
+ restartRequired: envVar?.restartRequired || false
2290
+ });
2291
+ } catch (error) {
2292
+ res.status(500).json({
2293
+ error: "Failed to update environment variable",
2294
+ details: error instanceof Error ? error.message : String(error)
2295
+ });
2296
+ }
2297
+ });
2298
+ router.post("/api/env/bulk", requireAuth, requireAdmin, (req, res) => {
2299
+ try {
2300
+ const { variables } = req.body;
2301
+ if (!variables || typeof variables !== "object") {
2302
+ res.status(400).json({ error: "Invalid request body" });
2303
+ return;
2304
+ }
2305
+ const errors = {};
2306
+ Object.entries(variables).forEach(([key, value]) => {
2307
+ const validation = validateEnvValue(key, value);
2308
+ if (!validation.valid) {
2309
+ errors[key] = validation.error || "Invalid value";
2310
+ }
2311
+ });
2312
+ if (Object.keys(errors).length > 0) {
2313
+ res.status(400).json({ error: "Validation failed", errors });
2314
+ return;
2315
+ }
2316
+ const env = readEnvFile();
2317
+ Object.entries(variables).forEach(([key, value]) => {
2318
+ if (value === "" || value === null || value === void 0) {
2319
+ delete env[key];
2320
+ delete process.env[key];
2321
+ } else {
2322
+ env[key] = value;
2323
+ process.env[key] = value;
2324
+ }
2325
+ });
2326
+ writeEnvFile(env);
2327
+ const restartRequired = Object.keys(variables).some((key) => {
2328
+ const envVar = ENV_VARIABLES.find((v) => v.key === key);
2329
+ return envVar?.restartRequired;
2330
+ });
2331
+ res.json({
2332
+ success: true,
2333
+ updated: Object.keys(variables).length,
2334
+ restartRequired
2335
+ });
2336
+ } catch (error) {
2337
+ res.status(500).json({
2338
+ error: "Failed to update environment variables",
2339
+ details: error instanceof Error ? error.message : String(error)
2340
+ });
2341
+ }
2342
+ });
2343
+ router.post("/api/env/test/:key", requireAuth, requireAdmin, async (req, res) => {
2344
+ try {
2345
+ const { key } = req.params;
2346
+ const { value } = req.body;
2347
+ const envVar = ENV_VARIABLES.find((v) => v.key === key);
2348
+ if (!envVar || !envVar.testable) {
2349
+ res.status(400).json({ error: "This variable cannot be tested" });
2350
+ return;
2351
+ }
2352
+ let testResult;
2353
+ switch (key) {
2354
+ case "OPENAI_API_KEY":
2355
+ testResult = await testOpenAIKey(value);
2356
+ break;
2357
+ case "ANTHROPIC_API_KEY":
2358
+ testResult = await testAnthropicKey(value);
2359
+ break;
2360
+ case "OLLAMA_BASE_URL":
2361
+ testResult = await testOllamaURL(value);
2362
+ break;
2363
+ case "GITHUB_TOKEN":
2364
+ testResult = await testGitHubToken(value);
2365
+ break;
2366
+ case "SAAS_URL":
2367
+ testResult = await testURL(value);
2368
+ break;
2369
+ case "SLACK_WEBHOOK_URL":
2370
+ testResult = await testSlackWebhook(value);
2371
+ break;
2372
+ case "DISCORD_WEBHOOK_URL":
2373
+ testResult = await testDiscordWebhook(value);
2374
+ break;
2375
+ default:
2376
+ testResult = { success: false, message: "Test not implemented for this variable" };
2377
+ }
2378
+ res.json(testResult);
2379
+ } catch (error) {
2380
+ res.status(500).json({
2381
+ success: false,
2382
+ message: error instanceof Error ? error.message : "Test failed"
2383
+ });
2384
+ }
2385
+ });
2386
+ router.post("/api/env/generate/:key", requireAuth, requireAdmin, (req, res) => {
2387
+ try {
2388
+ const { key } = req.params;
2389
+ let generated;
2390
+ switch (key) {
2391
+ case "OPENQA_JWT_SECRET":
2392
+ generated = Array.from(
2393
+ { length: 64 },
2394
+ () => Math.floor(Math.random() * 16).toString(16)
2395
+ ).join("");
2396
+ break;
2397
+ default:
2398
+ res.status(400).json({ error: "Generation not supported for this variable" });
2399
+ return;
2400
+ }
2401
+ res.json({ success: true, value: generated });
2402
+ } catch (error) {
2403
+ res.status(500).json({
2404
+ error: "Failed to generate value",
2405
+ details: error instanceof Error ? error.message : String(error)
2406
+ });
2407
+ }
2408
+ });
2409
+ return router;
2410
+ }
2411
+ async function testOpenAIKey(apiKey) {
2412
+ try {
2413
+ const response = await fetch("https://api.openai.com/v1/models", {
2414
+ headers: { "Authorization": `Bearer ${apiKey}` }
2415
+ });
2416
+ if (response.ok) {
2417
+ return { success: true, message: "OpenAI API key is valid" };
2418
+ }
2419
+ return { success: false, message: "Invalid OpenAI API key" };
2420
+ } catch {
2421
+ return { success: false, message: "Failed to connect to OpenAI API" };
2422
+ }
2423
+ }
2424
+ async function testAnthropicKey(apiKey) {
2425
+ try {
2426
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
2427
+ method: "POST",
2428
+ headers: {
2429
+ "x-api-key": apiKey,
2430
+ "anthropic-version": "2023-06-01",
2431
+ "content-type": "application/json"
2432
+ },
2433
+ body: JSON.stringify({
2434
+ model: "claude-3-haiku-20240307",
2435
+ max_tokens: 1,
2436
+ messages: [{ role: "user", content: "test" }]
2437
+ })
2438
+ });
2439
+ if (response.status === 200 || response.status === 400) {
2440
+ return { success: true, message: "Anthropic API key is valid" };
2441
+ }
2442
+ return { success: false, message: "Invalid Anthropic API key" };
2443
+ } catch {
2444
+ return { success: false, message: "Failed to connect to Anthropic API" };
2445
+ }
2446
+ }
2447
+ async function testOllamaURL(url) {
2448
+ try {
2449
+ const response = await fetch(`${url}/api/tags`);
2450
+ if (response.ok) {
2451
+ return { success: true, message: "Ollama server is accessible" };
2452
+ }
2453
+ return { success: false, message: "Ollama server returned an error" };
2454
+ } catch {
2455
+ return { success: false, message: "Cannot connect to Ollama server" };
2456
+ }
2457
+ }
2458
+ async function testGitHubToken(token) {
2459
+ try {
2460
+ const response = await fetch("https://api.github.com/user", {
2461
+ headers: { "Authorization": `token ${token}` }
2462
+ });
2463
+ if (response.ok) {
2464
+ const data = await response.json();
2465
+ return { success: true, message: `GitHub token is valid (user: ${data.login})` };
2466
+ }
2467
+ return { success: false, message: "Invalid GitHub token" };
2468
+ } catch {
2469
+ return { success: false, message: "Failed to connect to GitHub API" };
2470
+ }
2471
+ }
2472
+ async function testURL(url) {
2473
+ try {
2474
+ const response = await fetch(url, { method: "HEAD" });
2475
+ if (response.ok) {
2476
+ return { success: true, message: "URL is accessible" };
2477
+ }
2478
+ return { success: false, message: `URL returned status ${response.status}` };
2479
+ } catch {
2480
+ return { success: false, message: "Cannot connect to URL" };
2481
+ }
2482
+ }
2483
+ async function testSlackWebhook(url) {
2484
+ try {
2485
+ const response = await fetch(url, {
2486
+ method: "POST",
2487
+ headers: { "Content-Type": "application/json" },
2488
+ body: JSON.stringify({ text: "OpenQA webhook test" })
2489
+ });
2490
+ if (response.ok) {
2491
+ return { success: true, message: "Slack webhook is valid" };
2492
+ }
2493
+ return { success: false, message: "Invalid Slack webhook" };
2494
+ } catch {
2495
+ return { success: false, message: "Failed to connect to Slack webhook" };
2496
+ }
2497
+ }
2498
+ async function testDiscordWebhook(url) {
2499
+ try {
2500
+ const response = await fetch(url, {
2501
+ method: "POST",
2502
+ headers: { "Content-Type": "application/json" },
2503
+ body: JSON.stringify({ content: "OpenQA webhook test" })
2504
+ });
2505
+ if (response.ok || response.status === 204) {
2506
+ return { success: true, message: "Discord webhook is valid" };
2507
+ }
2508
+ return { success: false, message: "Invalid Discord webhook" };
2509
+ } catch {
2510
+ return { success: false, message: "Failed to connect to Discord webhook" };
2511
+ }
2512
+ }
2513
+
1471
2514
  // cli/dashboard.html.ts
1472
2515
  init_esm_shims();
1473
2516
  function getDashboardHTML() {
@@ -2212,6 +3255,9 @@ function getDashboardHTML() {
2212
3255
  <a class="nav-item" href="/config">
2213
3256
  <span class="icon">\u2699</span> Config
2214
3257
  </a>
3258
+ <a class="nav-item" href="/config/env">
3259
+ <span class="icon">\u{1F527}</span> Environment
3260
+ </a>
2215
3261
  </div>
2216
3262
 
2217
3263
  <div class="sidebar-footer">
@@ -4115,15 +5161,1111 @@ function getConfigHTML(cfg) {
4115
5161
  </html>`;
4116
5162
  }
4117
5163
 
4118
- // cli/server.ts
4119
- import chalk from "chalk";
4120
- async function startWebServer() {
4121
- const config = new ConfigManager();
4122
- const cfg = config.getConfigSync();
4123
- const db = new OpenQADatabase("./data/openqa.json");
4124
- const app = express();
4125
- app.use(express.json());
4126
- const wss = new WebSocketServer({ noServer: true });
5164
+ // cli/login.html.ts
5165
+ init_esm_shims();
5166
+ function getLoginHTML(opts) {
5167
+ const error = opts?.error ?? "";
5168
+ return `<!DOCTYPE html>
5169
+ <html lang="en">
5170
+ <head>
5171
+ <meta charset="UTF-8">
5172
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
5173
+ <title>OpenQA \u2014 Sign In</title>
5174
+ <link rel="preconnect" href="https://fonts.googleapis.com">
5175
+ <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@400;600;700;800&display=swap" rel="stylesheet">
5176
+ <style>
5177
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
5178
+ :root {
5179
+ --bg: #080b10;
5180
+ --surface: #0d1117;
5181
+ --panel: #111720;
5182
+ --border: rgba(255,255,255,0.06);
5183
+ --border-hi: rgba(255,255,255,0.14);
5184
+ --accent: #f97316;
5185
+ --accent-lo: rgba(249,115,22,0.08);
5186
+ --text-1: #f1f5f9;
5187
+ --text-2: #8b98a8;
5188
+ --text-3: #4b5563;
5189
+ --red: #ef4444;
5190
+ --red-lo: rgba(239,68,68,0.10);
5191
+ }
5192
+ html, body { height: 100%; }
5193
+ body {
5194
+ background: var(--bg);
5195
+ font-family: 'Syne', sans-serif;
5196
+ color: var(--text-1);
5197
+ display: flex;
5198
+ align-items: center;
5199
+ justify-content: center;
5200
+ min-height: 100vh;
5201
+ }
5202
+ .card {
5203
+ width: 100%;
5204
+ max-width: 400px;
5205
+ background: var(--surface);
5206
+ border: 1px solid var(--border-hi);
5207
+ border-radius: 16px;
5208
+ padding: 40px 36px 36px;
5209
+ box-shadow: 0 24px 64px rgba(0,0,0,0.6);
5210
+ }
5211
+ .logo {
5212
+ display: flex;
5213
+ align-items: center;
5214
+ gap: 10px;
5215
+ margin-bottom: 32px;
5216
+ justify-content: center;
5217
+ }
5218
+ .logo-mark {
5219
+ width: 36px; height: 36px;
5220
+ background: var(--accent);
5221
+ border-radius: 8px;
5222
+ display: flex; align-items: center; justify-content: center;
5223
+ font-size: 18px; font-weight: 800; color: #fff;
5224
+ }
5225
+ .logo-text { font-size: 22px; font-weight: 800; letter-spacing: -0.5px; }
5226
+ h1 { font-size: 20px; font-weight: 700; text-align: center; margin-bottom: 6px; }
5227
+ .subtitle { font-size: 13px; color: var(--text-2); text-align: center; margin-bottom: 28px; }
5228
+ .field { margin-bottom: 16px; }
5229
+ label { display: block; font-size: 12px; font-weight: 600; color: var(--text-2); margin-bottom: 6px; letter-spacing: 0.04em; text-transform: uppercase; }
5230
+ input[type=text], input[type=password] {
5231
+ width: 100%;
5232
+ background: var(--panel);
5233
+ border: 1px solid var(--border-hi);
5234
+ border-radius: 8px;
5235
+ padding: 10px 14px;
5236
+ font-family: 'DM Mono', monospace;
5237
+ font-size: 14px;
5238
+ color: var(--text-1);
5239
+ outline: none;
5240
+ transition: border-color 0.15s;
5241
+ }
5242
+ input:focus { border-color: var(--accent); }
5243
+ .error-msg {
5244
+ background: var(--red-lo);
5245
+ border: 1px solid rgba(239,68,68,0.3);
5246
+ border-radius: 8px;
5247
+ padding: 10px 14px;
5248
+ font-size: 13px;
5249
+ color: var(--red);
5250
+ margin-bottom: 18px;
5251
+ display: ${error ? "block" : "none"};
5252
+ }
5253
+ .btn {
5254
+ width: 100%;
5255
+ background: var(--accent);
5256
+ color: #fff;
5257
+ border: none;
5258
+ border-radius: 8px;
5259
+ padding: 12px;
5260
+ font-family: 'Syne', sans-serif;
5261
+ font-size: 15px;
5262
+ font-weight: 700;
5263
+ cursor: pointer;
5264
+ margin-top: 8px;
5265
+ transition: opacity 0.15s, transform 0.1s;
5266
+ }
5267
+ .btn:hover { opacity: 0.9; }
5268
+ .btn:active { transform: scale(0.98); }
5269
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
5270
+ .footer { margin-top: 28px; text-align: center; font-size: 12px; color: var(--text-3); font-family: 'DM Mono', monospace; }
5271
+ </style>
5272
+ </head>
5273
+ <body>
5274
+ <div class="card">
5275
+ <div class="logo">
5276
+ <div class="logo-mark">Q</div>
5277
+ <span class="logo-text">OpenQA</span>
5278
+ </div>
5279
+ <h1>Sign in</h1>
5280
+ <p class="subtitle">Access your QA dashboard</p>
5281
+
5282
+ <div class="error-msg" id="error">${error || "Invalid credentials"}</div>
5283
+
5284
+ <form id="loginForm">
5285
+ <div class="field">
5286
+ <label for="username">Username</label>
5287
+ <input type="text" id="username" name="username" autocomplete="username" autofocus required>
5288
+ </div>
5289
+ <div class="field">
5290
+ <label for="password">Password</label>
5291
+ <input type="password" id="password" name="password" autocomplete="current-password" required>
5292
+ </div>
5293
+ <button type="submit" class="btn" id="submitBtn">Sign In</button>
5294
+ </form>
5295
+
5296
+ <div class="footer">OpenQA v1.3.4</div>
5297
+ </div>
5298
+
5299
+ <script>
5300
+ const form = document.getElementById('loginForm');
5301
+ const errorEl = document.getElementById('error');
5302
+ const btn = document.getElementById('submitBtn');
5303
+
5304
+ form.addEventListener('submit', async (e) => {
5305
+ e.preventDefault();
5306
+ errorEl.style.display = 'none';
5307
+ btn.disabled = true;
5308
+ btn.textContent = 'Signing in\u2026';
5309
+ try {
5310
+ const res = await fetch('/api/auth/login', {
5311
+ method: 'POST',
5312
+ headers: { 'Content-Type': 'application/json' },
5313
+ body: JSON.stringify({
5314
+ username: document.getElementById('username').value.trim(),
5315
+ password: document.getElementById('password').value,
5316
+ }),
5317
+ credentials: 'include',
5318
+ });
5319
+ if (res.ok) {
5320
+ const params = new URLSearchParams(window.location.search);
5321
+ window.location.href = params.get('next') || '/';
5322
+ } else {
5323
+ const data = await res.json().catch(() => ({}));
5324
+ errorEl.textContent = data.error || 'Invalid credentials';
5325
+ errorEl.style.display = 'block';
5326
+ btn.disabled = false;
5327
+ btn.textContent = 'Sign In';
5328
+ }
5329
+ } catch {
5330
+ errorEl.textContent = 'Network error \u2014 please try again';
5331
+ errorEl.style.display = 'block';
5332
+ btn.disabled = false;
5333
+ btn.textContent = 'Sign In';
5334
+ }
5335
+ });
5336
+ </script>
5337
+ </body>
5338
+ </html>`;
5339
+ }
5340
+
5341
+ // cli/setup.html.ts
5342
+ init_esm_shims();
5343
+ function getSetupHTML() {
5344
+ return `<!DOCTYPE html>
5345
+ <html lang="en">
5346
+ <head>
5347
+ <meta charset="UTF-8">
5348
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
5349
+ <title>OpenQA \u2014 Setup</title>
5350
+ <link rel="preconnect" href="https://fonts.googleapis.com">
5351
+ <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@400;600;700;800&display=swap" rel="stylesheet">
5352
+ <style>
5353
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
5354
+ :root {
5355
+ --bg: #080b10;
5356
+ --surface: #0d1117;
5357
+ --panel: #111720;
5358
+ --border: rgba(255,255,255,0.06);
5359
+ --border-hi: rgba(255,255,255,0.14);
5360
+ --accent: #f97316;
5361
+ --accent-lo: rgba(249,115,22,0.08);
5362
+ --text-1: #f1f5f9;
5363
+ --text-2: #8b98a8;
5364
+ --text-3: #4b5563;
5365
+ --red: #ef4444;
5366
+ --red-lo: rgba(239,68,68,0.10);
5367
+ --green: #22c55e;
5368
+ --green-lo: rgba(34,197,94,0.08);
5369
+ }
5370
+ html, body { height: 100%; }
5371
+ body {
5372
+ background: var(--bg);
5373
+ font-family: 'Syne', sans-serif;
5374
+ color: var(--text-1);
5375
+ display: flex;
5376
+ align-items: center;
5377
+ justify-content: center;
5378
+ min-height: 100vh;
5379
+ }
5380
+ .card {
5381
+ width: 100%;
5382
+ max-width: 440px;
5383
+ background: var(--surface);
5384
+ border: 1px solid var(--border-hi);
5385
+ border-radius: 16px;
5386
+ padding: 40px 36px 36px;
5387
+ box-shadow: 0 24px 64px rgba(0,0,0,0.6);
5388
+ }
5389
+ .logo {
5390
+ display: flex; align-items: center; gap: 10px;
5391
+ margin-bottom: 28px; justify-content: center;
5392
+ }
5393
+ .logo-mark {
5394
+ width: 36px; height: 36px;
5395
+ background: var(--accent);
5396
+ border-radius: 8px;
5397
+ display: flex; align-items: center; justify-content: center;
5398
+ font-size: 18px; font-weight: 800; color: #fff;
5399
+ }
5400
+ .logo-text { font-size: 22px; font-weight: 800; letter-spacing: -0.5px; }
5401
+ h1 { font-size: 20px; font-weight: 700; text-align: center; margin-bottom: 6px; }
5402
+ .subtitle { font-size: 13px; color: var(--text-2); text-align: center; margin-bottom: 28px; }
5403
+ .badge {
5404
+ display: inline-block;
5405
+ background: var(--accent-lo);
5406
+ color: var(--accent);
5407
+ border: 1px solid rgba(249,115,22,0.25);
5408
+ border-radius: 6px;
5409
+ font-size: 11px; font-weight: 700; letter-spacing: 0.08em;
5410
+ padding: 3px 8px; margin-bottom: 20px;
5411
+ text-align: center; width: 100%;
5412
+ }
5413
+ .field { margin-bottom: 16px; }
5414
+ label { display: block; font-size: 12px; font-weight: 600; color: var(--text-2); margin-bottom: 6px; letter-spacing: 0.04em; text-transform: uppercase; }
5415
+ input[type=text], input[type=password] {
5416
+ width: 100%;
5417
+ background: var(--panel);
5418
+ border: 1px solid var(--border-hi);
5419
+ border-radius: 8px;
5420
+ padding: 10px 14px;
5421
+ font-family: 'DM Mono', monospace;
5422
+ font-size: 14px;
5423
+ color: var(--text-1);
5424
+ outline: none;
5425
+ transition: border-color 0.15s;
5426
+ }
5427
+ input:focus { border-color: var(--accent); }
5428
+ .hint { font-size: 11px; color: var(--text-3); margin-top: 5px; font-family: 'DM Mono', monospace; }
5429
+ .msg {
5430
+ border-radius: 8px;
5431
+ padding: 10px 14px;
5432
+ font-size: 13px;
5433
+ margin-bottom: 18px;
5434
+ display: none;
5435
+ }
5436
+ .msg.error { background: var(--red-lo); border: 1px solid rgba(239,68,68,0.3); color: var(--red); }
5437
+ .msg.success { background: var(--green-lo); border: 1px solid rgba(34,197,94,0.3); color: var(--green); }
5438
+ .btn {
5439
+ width: 100%;
5440
+ background: var(--accent);
5441
+ color: #fff;
5442
+ border: none;
5443
+ border-radius: 8px;
5444
+ padding: 12px;
5445
+ font-family: 'Syne', sans-serif;
5446
+ font-size: 15px; font-weight: 700;
5447
+ cursor: pointer; margin-top: 8px;
5448
+ transition: opacity 0.15s, transform 0.1s;
5449
+ }
5450
+ .btn:hover { opacity: 0.9; }
5451
+ .btn:active { transform: scale(0.98); }
5452
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
5453
+ .footer { margin-top: 28px; text-align: center; font-size: 12px; color: var(--text-3); font-family: 'DM Mono', monospace; }
5454
+ </style>
5455
+ </head>
5456
+ <body>
5457
+ <div class="card">
5458
+ <div class="logo">
5459
+ <div class="logo-mark">Q</div>
5460
+ <span class="logo-text">OpenQA</span>
5461
+ </div>
5462
+ <div class="badge">FIRST RUN</div>
5463
+ <h1>Create admin account</h1>
5464
+ <p class="subtitle">Set up your OpenQA instance</p>
5465
+
5466
+ <div class="msg error" id="error"></div>
5467
+ <div class="msg success" id="success"></div>
5468
+
5469
+ <form id="setupForm">
5470
+ <div class="field">
5471
+ <label for="username">Username or Email</label>
5472
+ <input type="text" id="username" name="username" autocomplete="username" autofocus required pattern="[a-z0-9_.@-]+" title="Lowercase letters, digits, and ._@- characters">
5473
+ <div class="hint">Use your email or a username (lowercase, digits, ._@- allowed)</div>
5474
+ </div>
5475
+ <div class="field">
5476
+ <label for="password">Password</label>
5477
+ <input type="password" id="password" name="password" autocomplete="new-password" required minlength="8">
5478
+ <div class="hint">Minimum 8 characters</div>
5479
+ </div>
5480
+ <div class="field">
5481
+ <label for="confirm">Confirm Password</label>
5482
+ <input type="password" id="confirm" name="confirm" autocomplete="new-password" required minlength="8">
5483
+ </div>
5484
+ <button type="submit" class="btn" id="submitBtn">Create Account &amp; Sign In</button>
5485
+ </form>
5486
+
5487
+ <div class="footer">OpenQA v1.3.4</div>
5488
+ </div>
5489
+
5490
+ <script>
5491
+ const form = document.getElementById('setupForm');
5492
+ const errorEl = document.getElementById('error');
5493
+ const successEl = document.getElementById('success');
5494
+ const btn = document.getElementById('submitBtn');
5495
+
5496
+ form.addEventListener('submit', async (e) => {
5497
+ e.preventDefault();
5498
+ errorEl.style.display = 'none';
5499
+ successEl.style.display = 'none';
5500
+
5501
+ const password = document.getElementById('password').value;
5502
+ const confirm = document.getElementById('confirm').value;
5503
+ if (password !== confirm) {
5504
+ errorEl.textContent = 'Passwords do not match';
5505
+ errorEl.style.display = 'block';
5506
+ return;
5507
+ }
5508
+
5509
+ btn.disabled = true;
5510
+ btn.textContent = 'Creating account\u2026';
5511
+
5512
+ try {
5513
+ const res = await fetch('/api/setup', {
5514
+ method: 'POST',
5515
+ headers: { 'Content-Type': 'application/json' },
5516
+ body: JSON.stringify({
5517
+ username: document.getElementById('username').value.trim(),
5518
+ password,
5519
+ }),
5520
+ credentials: 'include',
5521
+ });
5522
+
5523
+ if (res.ok) {
5524
+ successEl.textContent = 'Account created! Redirecting\u2026';
5525
+ successEl.style.display = 'block';
5526
+ setTimeout(() => { window.location.href = '/'; }, 800);
5527
+ } else {
5528
+ const data = await res.json().catch(() => ({}));
5529
+ errorEl.textContent = data.error || 'Setup failed';
5530
+ errorEl.style.display = 'block';
5531
+ btn.disabled = false;
5532
+ btn.textContent = 'Create Account & Sign In';
5533
+ }
5534
+ } catch {
5535
+ errorEl.textContent = 'Network error \u2014 please try again';
5536
+ errorEl.style.display = 'block';
5537
+ btn.disabled = false;
5538
+ btn.textContent = 'Create Account & Sign In';
5539
+ }
5540
+ });
5541
+ </script>
5542
+ </body>
5543
+ </html>`;
5544
+ }
5545
+
5546
+ // cli/env.html.ts
5547
+ init_esm_shims();
5548
+ function getEnvHTML() {
5549
+ return `<!DOCTYPE html>
5550
+ <html lang="en">
5551
+ <head>
5552
+ <meta charset="UTF-8">
5553
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
5554
+ <title>Environment Variables - OpenQA</title>
5555
+ <style>
5556
+ * { margin: 0; padding: 0; box-sizing: border-box; }
5557
+
5558
+ body {
5559
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
5560
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
5561
+ min-height: 100vh;
5562
+ padding: 20px;
5563
+ }
5564
+
5565
+ .container {
5566
+ max-width: 1200px;
5567
+ margin: 0 auto;
5568
+ }
5569
+
5570
+ .header {
5571
+ background: rgba(255, 255, 255, 0.95);
5572
+ backdrop-filter: blur(10px);
5573
+ padding: 20px 30px;
5574
+ border-radius: 12px;
5575
+ margin-bottom: 20px;
5576
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
5577
+ display: flex;
5578
+ justify-content: space-between;
5579
+ align-items: center;
5580
+ }
5581
+
5582
+ .header h1 {
5583
+ font-size: 24px;
5584
+ color: #1a202c;
5585
+ display: flex;
5586
+ align-items: center;
5587
+ gap: 10px;
5588
+ }
5589
+
5590
+ .header-actions {
5591
+ display: flex;
5592
+ gap: 10px;
5593
+ }
5594
+
5595
+ .btn {
5596
+ padding: 10px 20px;
5597
+ border: none;
5598
+ border-radius: 8px;
5599
+ font-size: 14px;
5600
+ font-weight: 600;
5601
+ cursor: pointer;
5602
+ transition: all 0.2s;
5603
+ text-decoration: none;
5604
+ display: inline-flex;
5605
+ align-items: center;
5606
+ gap: 8px;
5607
+ }
5608
+
5609
+ .btn-primary {
5610
+ background: #667eea;
5611
+ color: white;
5612
+ }
5613
+
5614
+ .btn-primary:hover {
5615
+ background: #5568d3;
5616
+ transform: translateY(-1px);
5617
+ }
5618
+
5619
+ .btn-secondary {
5620
+ background: #e2e8f0;
5621
+ color: #4a5568;
5622
+ }
5623
+
5624
+ .btn-secondary:hover {
5625
+ background: #cbd5e0;
5626
+ }
5627
+
5628
+ .btn-success {
5629
+ background: #48bb78;
5630
+ color: white;
5631
+ }
5632
+
5633
+ .btn-success:hover {
5634
+ background: #38a169;
5635
+ }
5636
+
5637
+ .btn:disabled {
5638
+ opacity: 0.5;
5639
+ cursor: not-allowed;
5640
+ }
5641
+
5642
+ .content {
5643
+ background: rgba(255, 255, 255, 0.95);
5644
+ backdrop-filter: blur(10px);
5645
+ padding: 30px;
5646
+ border-radius: 12px;
5647
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
5648
+ }
5649
+
5650
+ .tabs {
5651
+ display: flex;
5652
+ gap: 10px;
5653
+ margin-bottom: 30px;
5654
+ border-bottom: 2px solid #e2e8f0;
5655
+ padding-bottom: 10px;
5656
+ }
5657
+
5658
+ .tab {
5659
+ padding: 10px 20px;
5660
+ border: none;
5661
+ background: none;
5662
+ font-size: 14px;
5663
+ font-weight: 600;
5664
+ color: #718096;
5665
+ cursor: pointer;
5666
+ border-bottom: 3px solid transparent;
5667
+ transition: all 0.2s;
5668
+ }
5669
+
5670
+ .tab.active {
5671
+ color: #667eea;
5672
+ border-bottom-color: #667eea;
5673
+ }
5674
+
5675
+ .tab:hover {
5676
+ color: #667eea;
5677
+ }
5678
+
5679
+ .category-section {
5680
+ display: none;
5681
+ }
5682
+
5683
+ .category-section.active {
5684
+ display: block;
5685
+ }
5686
+
5687
+ .category-header {
5688
+ display: flex;
5689
+ justify-content: space-between;
5690
+ align-items: center;
5691
+ margin-bottom: 20px;
5692
+ }
5693
+
5694
+ .category-title {
5695
+ font-size: 18px;
5696
+ font-weight: 600;
5697
+ color: #2d3748;
5698
+ }
5699
+
5700
+ .env-grid {
5701
+ display: grid;
5702
+ gap: 20px;
5703
+ }
5704
+
5705
+ .env-item {
5706
+ border: 1px solid #e2e8f0;
5707
+ border-radius: 8px;
5708
+ padding: 20px;
5709
+ transition: all 0.2s;
5710
+ }
5711
+
5712
+ .env-item:hover {
5713
+ border-color: #cbd5e0;
5714
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
5715
+ }
5716
+
5717
+ .env-item-header {
5718
+ display: flex;
5719
+ justify-content: space-between;
5720
+ align-items: flex-start;
5721
+ margin-bottom: 10px;
5722
+ }
5723
+
5724
+ .env-label {
5725
+ font-weight: 600;
5726
+ color: #2d3748;
5727
+ font-size: 14px;
5728
+ display: flex;
5729
+ align-items: center;
5730
+ gap: 8px;
5731
+ }
5732
+
5733
+ .required-badge {
5734
+ background: #fc8181;
5735
+ color: white;
5736
+ font-size: 10px;
5737
+ padding: 2px 6px;
5738
+ border-radius: 4px;
5739
+ font-weight: 700;
5740
+ }
5741
+
5742
+ .env-description {
5743
+ font-size: 13px;
5744
+ color: #718096;
5745
+ margin-bottom: 10px;
5746
+ }
5747
+
5748
+ .env-input-group {
5749
+ display: flex;
5750
+ gap: 10px;
5751
+ align-items: center;
5752
+ }
5753
+
5754
+ .env-input {
5755
+ flex: 1;
5756
+ padding: 10px 12px;
5757
+ border: 1px solid #e2e8f0;
5758
+ border-radius: 6px;
5759
+ font-size: 14px;
5760
+ font-family: 'Monaco', 'Courier New', monospace;
5761
+ transition: all 0.2s;
5762
+ }
5763
+
5764
+ .env-input:focus {
5765
+ outline: none;
5766
+ border-color: #667eea;
5767
+ box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
5768
+ }
5769
+
5770
+ .env-input.error {
5771
+ border-color: #fc8181;
5772
+ }
5773
+
5774
+ .env-actions {
5775
+ display: flex;
5776
+ gap: 5px;
5777
+ }
5778
+
5779
+ .icon-btn {
5780
+ padding: 8px;
5781
+ border: none;
5782
+ background: #e2e8f0;
5783
+ border-radius: 6px;
5784
+ cursor: pointer;
5785
+ transition: all 0.2s;
5786
+ font-size: 16px;
5787
+ }
5788
+
5789
+ .icon-btn:hover {
5790
+ background: #cbd5e0;
5791
+ }
5792
+
5793
+ .icon-btn.test {
5794
+ background: #bee3f8;
5795
+ color: #2c5282;
5796
+ }
5797
+
5798
+ .icon-btn.test:hover {
5799
+ background: #90cdf4;
5800
+ }
5801
+
5802
+ .icon-btn.generate {
5803
+ background: #c6f6d5;
5804
+ color: #22543d;
5805
+ }
5806
+
5807
+ .icon-btn.generate:hover {
5808
+ background: #9ae6b4;
5809
+ }
5810
+
5811
+ .error-message {
5812
+ color: #e53e3e;
5813
+ font-size: 12px;
5814
+ margin-top: 5px;
5815
+ }
5816
+
5817
+ .success-message {
5818
+ color: #38a169;
5819
+ font-size: 12px;
5820
+ margin-top: 5px;
5821
+ }
5822
+
5823
+ .alert {
5824
+ padding: 15px 20px;
5825
+ border-radius: 8px;
5826
+ margin-bottom: 20px;
5827
+ display: flex;
5828
+ align-items: center;
5829
+ gap: 10px;
5830
+ }
5831
+
5832
+ .alert-warning {
5833
+ background: #fef5e7;
5834
+ border-left: 4px solid #f59e0b;
5835
+ color: #92400e;
5836
+ }
5837
+
5838
+ .alert-info {
5839
+ background: #eff6ff;
5840
+ border-left: 4px solid #3b82f6;
5841
+ color: #1e40af;
5842
+ }
5843
+
5844
+ .alert-success {
5845
+ background: #f0fdf4;
5846
+ border-left: 4px solid #10b981;
5847
+ color: #065f46;
5848
+ }
5849
+
5850
+ .loading {
5851
+ text-align: center;
5852
+ padding: 40px;
5853
+ color: #718096;
5854
+ }
5855
+
5856
+ .spinner {
5857
+ border: 3px solid #e2e8f0;
5858
+ border-top: 3px solid #667eea;
5859
+ border-radius: 50%;
5860
+ width: 40px;
5861
+ height: 40px;
5862
+ animation: spin 1s linear infinite;
5863
+ margin: 0 auto 20px;
5864
+ }
5865
+
5866
+ @keyframes spin {
5867
+ 0% { transform: rotate(0deg); }
5868
+ 100% { transform: rotate(360deg); }
5869
+ }
5870
+
5871
+ .modal {
5872
+ display: none;
5873
+ position: fixed;
5874
+ top: 0;
5875
+ left: 0;
5876
+ right: 0;
5877
+ bottom: 0;
5878
+ background: rgba(0, 0, 0, 0.5);
5879
+ z-index: 1000;
5880
+ align-items: center;
5881
+ justify-content: center;
5882
+ }
5883
+
5884
+ .modal.show {
5885
+ display: flex;
5886
+ }
5887
+
5888
+ .modal-content {
5889
+ background: white;
5890
+ padding: 30px;
5891
+ border-radius: 12px;
5892
+ max-width: 500px;
5893
+ width: 90%;
5894
+ box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
5895
+ }
5896
+
5897
+ .modal-header {
5898
+ font-size: 20px;
5899
+ font-weight: 600;
5900
+ margin-bottom: 15px;
5901
+ color: #2d3748;
5902
+ }
5903
+
5904
+ .modal-body {
5905
+ margin-bottom: 20px;
5906
+ color: #4a5568;
5907
+ }
5908
+
5909
+ .modal-footer {
5910
+ display: flex;
5911
+ gap: 10px;
5912
+ justify-content: flex-end;
5913
+ }
5914
+ </style>
5915
+ </head>
5916
+ <body>
5917
+ <div class="container">
5918
+ <div class="header">
5919
+ <h1>
5920
+ <span>\u2699\uFE0F</span>
5921
+ Environment Variables
5922
+ </h1>
5923
+ <div class="header-actions">
5924
+ <a href="/config" class="btn btn-secondary">\u2190 Back to Config</a>
5925
+ <button id="saveBtn" class="btn btn-success" disabled>\u{1F4BE} Save Changes</button>
5926
+ </div>
5927
+ </div>
5928
+
5929
+ <div class="content">
5930
+ <div id="loading" class="loading">
5931
+ <div class="spinner"></div>
5932
+ <div>Loading environment variables...</div>
5933
+ </div>
5934
+
5935
+ <div id="main" style="display: none;">
5936
+ <div id="alerts"></div>
5937
+
5938
+ <div class="tabs">
5939
+ <button class="tab active" data-category="llm">\u{1F916} LLM</button>
5940
+ <button class="tab" data-category="security">\u{1F512} Security</button>
5941
+ <button class="tab" data-category="target">\u{1F3AF} Target App</button>
5942
+ <button class="tab" data-category="github">\u{1F419} GitHub</button>
5943
+ <button class="tab" data-category="web">\u{1F310} Web Server</button>
5944
+ <button class="tab" data-category="agent">\u{1F916} Agent</button>
5945
+ <button class="tab" data-category="database">\u{1F4BE} Database</button>
5946
+ <button class="tab" data-category="notifications">\u{1F514} Notifications</button>
5947
+ </div>
5948
+
5949
+ <div id="categories"></div>
5950
+ </div>
5951
+ </div>
5952
+ </div>
5953
+
5954
+ <!-- Test Result Modal -->
5955
+ <div id="testModal" class="modal">
5956
+ <div class="modal-content">
5957
+ <div class="modal-header">Test Result</div>
5958
+ <div class="modal-body" id="testResult"></div>
5959
+ <div class="modal-footer">
5960
+ <button class="btn btn-secondary" onclick="closeTestModal()">Close</button>
5961
+ </div>
5962
+ </div>
5963
+ </div>
5964
+
5965
+ <script>
5966
+ let envVariables = [];
5967
+ let changedVariables = {};
5968
+ let restartRequired = false;
5969
+
5970
+ // Load environment variables
5971
+ async function loadEnvVariables() {
5972
+ try {
5973
+ const response = await fetch('/api/env');
5974
+ if (!response.ok) throw new Error('Failed to load variables');
5975
+
5976
+ const data = await response.json();
5977
+ envVariables = data.variables;
5978
+
5979
+ renderCategories();
5980
+ document.getElementById('loading').style.display = 'none';
5981
+ document.getElementById('main').style.display = 'block';
5982
+ } catch (error) {
5983
+ showAlert('error', 'Failed to load environment variables: ' + error.message);
5984
+ }
5985
+ }
5986
+
5987
+ // Render categories
5988
+ function renderCategories() {
5989
+ const container = document.getElementById('categories');
5990
+ const categories = [...new Set(envVariables.map(v => v.category))];
5991
+
5992
+ categories.forEach((category, index) => {
5993
+ const section = document.createElement('div');
5994
+ section.className = 'category-section' + (index === 0 ? ' active' : '');
5995
+ section.dataset.category = category;
5996
+
5997
+ const vars = envVariables.filter(v => v.category === category);
5998
+
5999
+ section.innerHTML = \`
6000
+ <div class="category-header">
6001
+ <div class="category-title">\${getCategoryTitle(category)}</div>
6002
+ </div>
6003
+ <div class="env-grid">
6004
+ \${vars.map(v => renderEnvItem(v)).join('')}
6005
+ </div>
6006
+ \`;
6007
+
6008
+ container.appendChild(section);
6009
+ });
6010
+ }
6011
+
6012
+ // Render single env item
6013
+ function renderEnvItem(envVar) {
6014
+ const inputType = envVar.type === 'password' ? 'password' : 'text';
6015
+ const value = envVar.displayValue || '';
6016
+
6017
+ return \`
6018
+ <div class="env-item" data-key="\${envVar.key}">
6019
+ <div class="env-item-header">
6020
+ <div class="env-label">
6021
+ \${envVar.key}
6022
+ \${envVar.required ? '<span class="required-badge">REQUIRED</span>' : ''}
6023
+ </div>
6024
+ </div>
6025
+ <div class="env-description">\${envVar.description}</div>
6026
+ <div class="env-input-group">
6027
+ \${envVar.type === 'select' ?
6028
+ \`<select class="env-input" data-key="\${envVar.key}" onchange="handleChange(this)">
6029
+ <option value="">-- Select --</option>
6030
+ \${envVar.options.map(opt =>
6031
+ \`<option value="\${opt}" \${value === opt ? 'selected' : ''}>\${opt}</option>\`
6032
+ ).join('')}
6033
+ </select>\` :
6034
+ envVar.type === 'boolean' ?
6035
+ \`<select class="env-input" data-key="\${envVar.key}" onchange="handleChange(this)">
6036
+ <option value="">-- Select --</option>
6037
+ <option value="true" \${value === 'true' ? 'selected' : ''}>true</option>
6038
+ <option value="false" \${value === 'false' ? 'selected' : ''}>false</option>
6039
+ </select>\` :
6040
+ \`<input
6041
+ type="\${inputType}"
6042
+ class="env-input"
6043
+ data-key="\${envVar.key}"
6044
+ value="\${value}"
6045
+ placeholder="\${envVar.placeholder || ''}"
6046
+ onchange="handleChange(this)"
6047
+ />\`
6048
+ }
6049
+ <div class="env-actions">
6050
+ \${envVar.testable ? \`<button class="icon-btn test" onclick="testVariable('\${envVar.key}')" title="Test">\u{1F9EA}</button>\` : ''}
6051
+ \${envVar.key === 'OPENQA_JWT_SECRET' ? \`<button class="icon-btn generate" onclick="generateSecret('\${envVar.key}')" title="Generate">\u{1F511}</button>\` : ''}
6052
+ </div>
6053
+ </div>
6054
+ <div class="error-message" id="error-\${envVar.key}"></div>
6055
+ <div class="success-message" id="success-\${envVar.key}"></div>
6056
+ </div>
6057
+ \`;
6058
+ }
6059
+
6060
+ // Handle input change
6061
+ function handleChange(input) {
6062
+ const key = input.dataset.key;
6063
+ const value = input.value;
6064
+
6065
+ changedVariables[key] = value;
6066
+ document.getElementById('saveBtn').disabled = false;
6067
+
6068
+ // Clear messages
6069
+ document.getElementById(\`error-\${key}\`).textContent = '';
6070
+ document.getElementById(\`success-\${key}\`).textContent = '';
6071
+ }
6072
+
6073
+ // Save changes
6074
+ async function saveChanges() {
6075
+ const saveBtn = document.getElementById('saveBtn');
6076
+ saveBtn.disabled = true;
6077
+ saveBtn.textContent = '\u{1F4BE} Saving...';
6078
+
6079
+ try {
6080
+ const response = await fetch('/api/env/bulk', {
6081
+ method: 'POST',
6082
+ headers: { 'Content-Type': 'application/json' },
6083
+ body: JSON.stringify({ variables: changedVariables }),
6084
+ });
6085
+
6086
+ if (!response.ok) {
6087
+ const error = await response.json();
6088
+ throw new Error(error.error || 'Failed to save');
6089
+ }
6090
+
6091
+ const result = await response.json();
6092
+ restartRequired = result.restartRequired;
6093
+
6094
+ showAlert('success', \`\u2705 Saved \${result.updated} variable(s) successfully!\` +
6095
+ (restartRequired ? ' \u26A0\uFE0F Restart required for changes to take effect.' : ''));
6096
+
6097
+ changedVariables = {};
6098
+ saveBtn.textContent = '\u{1F4BE} Save Changes';
6099
+
6100
+ // Reload to show updated values
6101
+ setTimeout(() => location.reload(), 2000);
6102
+ } catch (error) {
6103
+ showAlert('error', 'Failed to save: ' + error.message);
6104
+ saveBtn.disabled = false;
6105
+ saveBtn.textContent = '\u{1F4BE} Save Changes';
6106
+ }
6107
+ }
6108
+
6109
+ // Test variable
6110
+ async function testVariable(key) {
6111
+ const input = document.querySelector(\`[data-key="\${key}"]\`);
6112
+ const value = input.value;
6113
+
6114
+ if (!value) {
6115
+ showAlert('warning', 'Please enter a value first');
6116
+ return;
6117
+ }
6118
+
6119
+ try {
6120
+ const response = await fetch(\`/api/env/test/\${key}\`, {
6121
+ method: 'POST',
6122
+ headers: { 'Content-Type': 'application/json' },
6123
+ body: JSON.stringify({ value }),
6124
+ });
6125
+
6126
+ const result = await response.json();
6127
+ showTestResult(result);
6128
+ } catch (error) {
6129
+ showTestResult({ success: false, message: 'Test failed: ' + error.message });
6130
+ }
6131
+ }
6132
+
6133
+ // Generate secret
6134
+ async function generateSecret(key) {
6135
+ try {
6136
+ const response = await fetch(\`/api/env/generate/\${key}\`, {
6137
+ method: 'POST',
6138
+ });
6139
+
6140
+ if (!response.ok) throw new Error('Failed to generate');
6141
+
6142
+ const result = await response.json();
6143
+ const input = document.querySelector(\`[data-key="\${key}"]\`);
6144
+ input.value = result.value;
6145
+ handleChange(input);
6146
+
6147
+ document.getElementById(\`success-\${key}\`).textContent = '\u2705 Secret generated!';
6148
+ } catch (error) {
6149
+ document.getElementById(\`error-\${key}\`).textContent = 'Failed to generate: ' + error.message;
6150
+ }
6151
+ }
6152
+
6153
+ // Show test result
6154
+ function showTestResult(result) {
6155
+ const modal = document.getElementById('testModal');
6156
+ const resultDiv = document.getElementById('testResult');
6157
+
6158
+ resultDiv.innerHTML = \`
6159
+ <div class="alert \${result.success ? 'alert-success' : 'alert-warning'}">
6160
+ \${result.success ? '\u2705' : '\u274C'} \${result.message}
6161
+ </div>
6162
+ \`;
6163
+
6164
+ modal.classList.add('show');
6165
+ }
6166
+
6167
+ function closeTestModal() {
6168
+ document.getElementById('testModal').classList.remove('show');
6169
+ }
6170
+
6171
+ // Show alert
6172
+ function showAlert(type, message) {
6173
+ const alerts = document.getElementById('alerts');
6174
+ const alertClass = type === 'error' ? 'alert-warning' :
6175
+ type === 'success' ? 'alert-success' : 'alert-info';
6176
+
6177
+ alerts.innerHTML = \`
6178
+ <div class="alert \${alertClass}">
6179
+ \${message}
6180
+ </div>
6181
+ \`;
6182
+
6183
+ setTimeout(() => alerts.innerHTML = '', 5000);
6184
+ }
6185
+
6186
+ // Get category title
6187
+ function getCategoryTitle(category) {
6188
+ const titles = {
6189
+ llm: '\u{1F916} LLM Configuration',
6190
+ security: '\u{1F512} Security Settings',
6191
+ target: '\u{1F3AF} Target Application',
6192
+ github: '\u{1F419} GitHub Integration',
6193
+ web: '\u{1F310} Web Server',
6194
+ agent: '\u{1F916} Agent Configuration',
6195
+ database: '\u{1F4BE} Database',
6196
+ notifications: '\u{1F514} Notifications',
6197
+ };
6198
+ return titles[category] || category;
6199
+ }
6200
+
6201
+ // Tab switching
6202
+ document.addEventListener('click', (e) => {
6203
+ if (e.target.classList.contains('tab')) {
6204
+ const category = e.target.dataset.category;
6205
+
6206
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
6207
+ e.target.classList.add('active');
6208
+
6209
+ document.querySelectorAll('.category-section').forEach(s => s.classList.remove('active'));
6210
+ document.querySelector(\`[data-category="\${category}"]\`).classList.add('active');
6211
+ }
6212
+ });
6213
+
6214
+ // Save button
6215
+ document.getElementById('saveBtn').addEventListener('click', saveChanges);
6216
+
6217
+ // Load on page load
6218
+ loadEnvVariables();
6219
+ </script>
6220
+ </body>
6221
+ </html>`;
6222
+ }
6223
+
6224
+ // cli/server.ts
6225
+ import chalk from "chalk";
6226
+ import rateLimit from "express-rate-limit";
6227
+ import cors from "cors";
6228
+ async function startWebServer() {
6229
+ const config = new ConfigManager();
6230
+ const cfg = config.getConfigSync();
6231
+ const db = new OpenQADatabase("./data/openqa.json");
6232
+ const app = express();
6233
+ const allowedOrigins = (process.env.CORS_ORIGINS || `http://localhost:${cfg.web.port}`).split(",");
6234
+ app.use(cors({
6235
+ origin: (origin, cb) => {
6236
+ if (!origin || allowedOrigins.includes(origin) || allowedOrigins.includes("*")) {
6237
+ cb(null, true);
6238
+ } else {
6239
+ cb(new Error(`CORS: origin ${origin} not allowed`));
6240
+ }
6241
+ },
6242
+ credentials: true
6243
+ }));
6244
+ app.use(express.json({ limit: "1mb" }));
6245
+ const apiLimiter = rateLimit({
6246
+ windowMs: 6e4,
6247
+ max: 300,
6248
+ standardHeaders: true,
6249
+ legacyHeaders: false,
6250
+ message: { error: "Too many requests, please try again later." }
6251
+ });
6252
+ app.use("/api/", apiLimiter);
6253
+ const mutationLimiter = rateLimit({
6254
+ windowMs: 6e4,
6255
+ max: 30,
6256
+ standardHeaders: true,
6257
+ legacyHeaders: false,
6258
+ message: { error: "Too many requests, please slow down." }
6259
+ });
6260
+ app.use(["/api/start", "/api/stop", "/api/auth/login"], mutationLimiter);
6261
+ const wss = new WebSocketServer({ noServer: true });
6262
+ app.use(createAuthRouter(db));
6263
+ app.use(createEnvRouter());
6264
+ app.use("/api", (req, res, next) => {
6265
+ const PUBLIC_PATHS = ["/auth/login", "/auth/logout", "/setup", "/health"];
6266
+ if (PUBLIC_PATHS.some((p) => req.path === p || req.path.startsWith(p + "/"))) return next();
6267
+ return requireAuth(req, res, next);
6268
+ });
4127
6269
  app.use(createApiRouter(db, config));
4128
6270
  app.post("/api/intervention/:id", async (req, res) => {
4129
6271
  const { id } = req.params;
@@ -4202,15 +6344,28 @@ async function startWebServer() {
4202
6344
  res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
4203
6345
  }
4204
6346
  });
4205
- app.get("/", (req, res) => {
6347
+ app.get("/login", async (_req, res) => {
6348
+ const count = await db.countUsers();
6349
+ if (count === 0) return res.redirect("/setup");
6350
+ res.send(getLoginHTML());
6351
+ });
6352
+ app.get("/setup", async (_req, res) => {
6353
+ const count = await db.countUsers();
6354
+ if (count > 0) return res.redirect("/login");
6355
+ res.send(getSetupHTML());
6356
+ });
6357
+ app.get("/", authOrRedirect(db), (_req, res) => {
4206
6358
  res.send(getDashboardHTML());
4207
6359
  });
4208
- app.get("/kanban", (req, res) => {
6360
+ app.get("/kanban", authOrRedirect(db), (_req, res) => {
4209
6361
  res.send(getKanbanHTML());
4210
6362
  });
4211
- app.get("/config", (req, res) => {
6363
+ app.get("/config", authOrRedirect(db), (_req, res) => {
4212
6364
  res.send(getConfigHTML(config.getConfigSync()));
4213
6365
  });
6366
+ app.get("/config/env", authOrRedirect(db), (_req, res) => {
6367
+ res.send(getEnvHTML());
6368
+ });
4214
6369
  const server = app.listen(cfg.web.port, cfg.web.host, () => {
4215
6370
  console.log(chalk.cyan("\n\u{1F4CA} OpenQA Status:"));
4216
6371
  console.log(chalk.white(` Agent: ${cfg.agent.autoStart ? "Auto-start enabled" : "Idle"}`));
@@ -4218,6 +6373,7 @@ async function startWebServer() {
4218
6373
  console.log(chalk.white(` Dashboard: http://localhost:${cfg.web.port}`));
4219
6374
  console.log(chalk.white(` Kanban: http://localhost:${cfg.web.port}/kanban`));
4220
6375
  console.log(chalk.white(` Config: http://localhost:${cfg.web.port}/config`));
6376
+ console.log(chalk.white(` Env Variables: http://localhost:${cfg.web.port}/config/env`));
4221
6377
  console.log(chalk.gray("\nPress Ctrl+C to stop\n"));
4222
6378
  if (!cfg.agent.autoStart) {
4223
6379
  console.log(chalk.yellow("\u{1F4A1} Auto-start disabled. Agent is idle."));