@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.
@@ -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() {
@@ -1546,7 +2589,7 @@ function getDashboardHTML() {
1546
2589
  .logo-mark {
1547
2590
  width: 34px;
1548
2591
  height: 34px;
1549
- background: var(--accent);
2592
+ background: transparent;
1550
2593
  border-radius: 8px;
1551
2594
  display: grid;
1552
2595
  place-items: center;
@@ -2168,7 +3211,9 @@ function getDashboardHTML() {
2168
3211
  <!-- Sidebar -->
2169
3212
  <aside>
2170
3213
  <div class="logo">
2171
- <div class="logo-mark">\u{1F52C}</div>
3214
+ <div class="logo-mark">
3215
+ <img src="https://openqa.orkajs.com/_next/image?url=https%3A%2F%2Forkajs.com%2Floutre-orka-qa.png&w=256&q=75" alt="OpenQA Logo" style="width: 40px; height: 40px;">
3216
+ </div>
2172
3217
  <div>
2173
3218
  <div class="logo-name">OpenQA</div>
2174
3219
  <div class="logo-version">v2.1.0 \xB7 OSS</div>
@@ -2178,39 +3223,69 @@ function getDashboardHTML() {
2178
3223
  <div class="nav-section">
2179
3224
  <div class="nav-label">Overview</div>
2180
3225
  <a class="nav-item active" href="/">
2181
- <span class="icon">\u25A6</span> Dashboard
3226
+ <span class="icon">
3227
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-gauge-icon lucide-gauge"><path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/></svg>
3228
+ </span> Dashboard
2182
3229
  </a>
2183
3230
  <a class="nav-item" href="/kanban">
2184
- <span class="icon">\u229E</span> Kanban
3231
+ <span class="icon">
3232
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-dashed-kanban-icon lucide-square-dashed-kanban"><path d="M8 7v7"/><path d="M12 7v4"/><path d="M16 7v9"/><path d="M5 3a2 2 0 0 0-2 2"/><path d="M9 3h1"/><path d="M14 3h1"/><path d="M19 3a2 2 0 0 1 2 2"/><path d="M21 9v1"/><path d="M21 14v1"/><path d="M21 19a2 2 0 0 1-2 2"/><path d="M14 21h1"/><path d="M9 21h1"/><path d="M5 21a2 2 0 0 1-2-2"/><path d="M3 14v1"/><path d="M3 9v1"/></svg>
3233
+ </span> Kanban
2185
3234
  <span class="badge" id="kanban-count">0</span>
2186
3235
  </a>
2187
3236
 
2188
3237
  <div class="nav-label">Agents</div>
2189
3238
  <a class="nav-item" href="javascript:void(0)" onclick="scrollToSection('agents-table')">
2190
- <span class="icon">\u25CE</span> Active Agents
3239
+ <span class="icon">
3240
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-activity-icon lucide-activity"><path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"/></svg>
3241
+ </span> Active Agents
2191
3242
  </a>
2192
3243
  <a class="nav-item" href="javascript:void(0)" onclick="switchAgentTab('specialists'); scrollToSection('agents-table')">
2193
- <span class="icon">\u25C7</span> Specialists
3244
+ <span class="icon">
3245
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-hat-glasses-icon lucide-hat-glasses"><path d="M14 18a2 2 0 0 0-4 0"/><path d="m19 11-2.11-6.657a2 2 0 0 0-2.752-1.148l-1.276.61A2 2 0 0 1 12 4H8.5a2 2 0 0 0-1.925 1.456L5 11"/><path d="M2 11h20"/><circle cx="17" cy="18" r="3"/><circle cx="7" cy="18" r="3"/></svg>
3246
+ </span> Specialists
2194
3247
  </a>
2195
3248
  <a class="nav-item" href="javascript:void(0)" onclick="scrollToSection('interventions-panel')">
2196
- <span class="icon">\u26A0</span> Interventions
3249
+ <span class="icon">
3250
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-user-cog-icon lucide-user-cog"><path d="M10 15H6a4 4 0 0 0-4 4v2"/><path d="m14.305 16.53.923-.382"/><path d="m15.228 13.852-.923-.383"/><path d="m16.852 12.228-.383-.923"/><path d="m16.852 17.772-.383.924"/><path d="m19.148 12.228.383-.923"/><path d="m19.53 18.696-.382-.924"/><path d="m20.772 13.852.924-.383"/><path d="m20.772 16.148.924.383"/><circle cx="18" cy="15" r="3"/><circle cx="9" cy="7" r="4"/></svg>
3251
+ </span> Interventions
2197
3252
  <span class="badge" id="intervention-count" style="background: var(--red);">0</span>
2198
3253
  </a>
2199
3254
 
2200
3255
  <div class="nav-label">Analysis</div>
2201
3256
  <a class="nav-item" href="javascript:void(0)" onclick="scrollToSection('issues-panel')">
2202
- <span class="icon">\u{1F41B}</span> Bug Reports
3257
+ <span class="icon">
3258
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bug-play-icon lucide-bug-play"><path d="M10 19.655A6 6 0 0 1 6 14v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 3.97"/><path d="M14 15.003a1 1 0 0 1 1.517-.859l4.997 2.997a1 1 0 0 1 0 1.718l-4.997 2.997a1 1 0 0 1-1.517-.86z"/>
3259
+ <path d="M14.12 3.88 16 2"/>
3260
+ <path d="M21 5a4 4 0 0 1-3.55 3.97"/>
3261
+ <path d="M3 21a4 4 0 0 1 3.81-4"/>
3262
+ <path d="M3 5a4 4 0 0 0 3.55 3.97"/>
3263
+ <path d="M6 13H2"/><path d="m8 2 1.88 1.88"/>
3264
+ <path d="M9 7.13V6a3 3 0 1 1 6 0v1.13"/>
3265
+ </svg>
3266
+ </span> Bug Reports
2203
3267
  </a>
2204
3268
  <a class="nav-item" href="javascript:void(0)" onclick="switchChartTab('performance'); scrollToSection('chart-performance')">
2205
- <span class="icon">\u26A1</span> Performance
3269
+ <span class="icon">
3270
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chart-spline-icon lucide-chart-spline"><path d="M3 3v16a2 2 0 0 0 2 2h16"/><path d="M7 16c.5-2 1.5-7 4-7 2 0 2 3 4 3 2.5 0 4.5-5 5-7"/></svg>
3271
+ </span> Performance
2206
3272
  </a>
2207
3273
  <a class="nav-item" href="javascript:void(0)" onclick="scrollToSection('activity-list')">
2208
- <span class="icon">\u{1F4CB}</span> Logs
3274
+ <span class="icon">
3275
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-scroll-text-icon lucide-scroll-text"><path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/></svg>
3276
+ </span> Logs
2209
3277
  </a>
2210
3278
 
2211
3279
  <div class="nav-label">System</div>
2212
3280
  <a class="nav-item" href="/config">
2213
- <span class="icon">\u2699</span> Config
3281
+ <span class="icon">
3282
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-columns3-cog-icon lucide-columns-3-cog"><path d="M10.5 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v5.5"/><path d="m14.3 19.6 1-.4"/><path d="M15 3v7.5"/><path d="m15.2 16.9-.9-.3"/><path d="m16.6 21.7.3-.9"/><path d="m16.8 15.3-.4-1"/><path d="m19.1 15.2.3-.9"/><path d="m19.6 21.7-.4-1"/><path d="m20.7 16.8 1-.4"/><path d="m21.7 19.4-.9-.3"/><path d="M9 3v18"/><circle cx="18" cy="18" r="3"/></svg>
3283
+ </span> Config
3284
+ </a>
3285
+ <a class="nav-item" href="/config/env">
3286
+ <span class="icon">
3287
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-columns3-cog-icon lucide-columns-3-cog"><path d="M10.5 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v5.5"/><path d="m14.3 19.6 1-.4"/><path d="M15 3v7.5"/><path d="m15.2 16.9-.9-.3"/><path d="m16.6 21.7.3-.9"/><path d="m16.8 15.3-.4-1"/><path d="m19.1 15.2.3-.9"/><path d="m19.6 21.7-.4-1"/><path d="m20.7 16.8 1-.4"/><path d="m21.7 19.4-.9-.3"/><path d="M9 3v18"/><circle cx="18" cy="18" r="3"/></svg>
3288
+ </span> Environment
2214
3289
  </a>
2215
3290
  </div>
2216
3291
 
@@ -2242,7 +3317,9 @@ function getDashboardHTML() {
2242
3317
  <div class="metric-card">
2243
3318
  <div class="metric-header">
2244
3319
  <div class="metric-label">Active Agents</div>
2245
- <div class="metric-icon">\u{1F916}</div>
3320
+ <div class="metric-icon">
3321
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-terminal-icon lucide-square-terminal"><path d="m7 11 2-2-2-2"/><path d="M11 13h4"/><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/></svg>
3322
+ </div>
2246
3323
  </div>
2247
3324
  <div class="metric-value" id="active-agents">0</div>
2248
3325
  <div class="metric-change positive" id="agents-change">\u2191 0 from last hour</div>
@@ -2250,7 +3327,9 @@ function getDashboardHTML() {
2250
3327
  <div class="metric-card">
2251
3328
  <div class="metric-header">
2252
3329
  <div class="metric-label">Total Actions</div>
2253
- <div class="metric-icon">\u26A1</div>
3330
+ <div class="metric-icon">
3331
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-list-todo-icon lucide-list-todo"><path d="M13 5h8"/><path d="M13 12h8"/><path d="M13 19h8"/><path d="m3 17 2 2 4-4"/><rect x="3" y="4" width="6" height="6" rx="1"/></svg>
3332
+ </div>
2254
3333
  </div>
2255
3334
  <div class="metric-value" id="total-actions">0</div>
2256
3335
  <div class="metric-change positive" id="actions-change">\u2191 0% this session</div>
@@ -2258,7 +3337,9 @@ function getDashboardHTML() {
2258
3337
  <div class="metric-card">
2259
3338
  <div class="metric-header">
2260
3339
  <div class="metric-label">Bugs Found</div>
2261
- <div class="metric-icon">\u{1F41B}</div>
3340
+ <div class="metric-icon">
3341
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-list-todo-icon lucide-list-todo"><path d="M13 5h8"/><path d="M13 12h8"/><path d="M13 19h8"/><path d="m3 17 2 2 4-4"/><rect x="3" y="4" width="6" height="6" rx="1"/></svg>
3342
+ </div>
2262
3343
  </div>
2263
3344
  <div class="metric-value" id="bugs-found">0</div>
2264
3345
  <div class="metric-change negative" id="bugs-change">\u2193 0 from yesterday</div>
@@ -2266,7 +3347,9 @@ function getDashboardHTML() {
2266
3347
  <div class="metric-card">
2267
3348
  <div class="metric-header">
2268
3349
  <div class="metric-label">Success Rate</div>
2269
- <div class="metric-icon">\u2713</div>
3350
+ <div class="metric-icon">
3351
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cloud-check-icon lucide-cloud-check"><path d="m17 15-5.5 5.5L9 18"/><path d="M5.516 16.07A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 3.501 7.327"/></svg>
3352
+ </div>
2270
3353
  </div>
2271
3354
  <div class="metric-value" id="success-rate">\u2014</div>
2272
3355
  <div class="metric-change positive" id="rate-change">\u2191 0 pts improvement</div>
@@ -4115,15 +5198,1111 @@ function getConfigHTML(cfg) {
4115
5198
  </html>`;
4116
5199
  }
4117
5200
 
5201
+ // cli/login.html.ts
5202
+ init_esm_shims();
5203
+ function getLoginHTML(opts) {
5204
+ const error = opts?.error ?? "";
5205
+ return `<!DOCTYPE html>
5206
+ <html lang="en">
5207
+ <head>
5208
+ <meta charset="UTF-8">
5209
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
5210
+ <title>OpenQA \u2014 Sign In</title>
5211
+ <link rel="preconnect" href="https://fonts.googleapis.com">
5212
+ <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@400;600;700;800&display=swap" rel="stylesheet">
5213
+ <style>
5214
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
5215
+ :root {
5216
+ --bg: #080b10;
5217
+ --surface: #0d1117;
5218
+ --panel: #111720;
5219
+ --border: rgba(255,255,255,0.06);
5220
+ --border-hi: rgba(255,255,255,0.14);
5221
+ --accent: #f97316;
5222
+ --accent-lo: rgba(249,115,22,0.08);
5223
+ --text-1: #f1f5f9;
5224
+ --text-2: #8b98a8;
5225
+ --text-3: #4b5563;
5226
+ --red: #ef4444;
5227
+ --red-lo: rgba(239,68,68,0.10);
5228
+ }
5229
+ html, body { height: 100%; }
5230
+ body {
5231
+ background: var(--bg);
5232
+ font-family: 'Syne', sans-serif;
5233
+ color: var(--text-1);
5234
+ display: flex;
5235
+ align-items: center;
5236
+ justify-content: center;
5237
+ min-height: 100vh;
5238
+ }
5239
+ .card {
5240
+ width: 100%;
5241
+ max-width: 400px;
5242
+ background: var(--surface);
5243
+ border: 1px solid var(--border-hi);
5244
+ border-radius: 16px;
5245
+ padding: 40px 36px 36px;
5246
+ box-shadow: 0 24px 64px rgba(0,0,0,0.6);
5247
+ }
5248
+ .logo {
5249
+ display: flex;
5250
+ align-items: center;
5251
+ gap: 10px;
5252
+ margin-bottom: 32px;
5253
+ justify-content: center;
5254
+ }
5255
+ .logo-mark {
5256
+ width: 36px; height: 36px;
5257
+ background: var(--accent);
5258
+ border-radius: 8px;
5259
+ display: flex; align-items: center; justify-content: center;
5260
+ font-size: 18px; font-weight: 800; color: #fff;
5261
+ }
5262
+ .logo-text { font-size: 22px; font-weight: 800; letter-spacing: -0.5px; }
5263
+ h1 { font-size: 20px; font-weight: 700; text-align: center; margin-bottom: 6px; }
5264
+ .subtitle { font-size: 13px; color: var(--text-2); text-align: center; margin-bottom: 28px; }
5265
+ .field { margin-bottom: 16px; }
5266
+ label { display: block; font-size: 12px; font-weight: 600; color: var(--text-2); margin-bottom: 6px; letter-spacing: 0.04em; text-transform: uppercase; }
5267
+ input[type=text], input[type=password] {
5268
+ width: 100%;
5269
+ background: var(--panel);
5270
+ border: 1px solid var(--border-hi);
5271
+ border-radius: 8px;
5272
+ padding: 10px 14px;
5273
+ font-family: 'DM Mono', monospace;
5274
+ font-size: 14px;
5275
+ color: var(--text-1);
5276
+ outline: none;
5277
+ transition: border-color 0.15s;
5278
+ }
5279
+ input:focus { border-color: var(--accent); }
5280
+ .error-msg {
5281
+ background: var(--red-lo);
5282
+ border: 1px solid rgba(239,68,68,0.3);
5283
+ border-radius: 8px;
5284
+ padding: 10px 14px;
5285
+ font-size: 13px;
5286
+ color: var(--red);
5287
+ margin-bottom: 18px;
5288
+ display: ${error ? "block" : "none"};
5289
+ }
5290
+ .btn {
5291
+ width: 100%;
5292
+ background: var(--accent);
5293
+ color: #fff;
5294
+ border: none;
5295
+ border-radius: 8px;
5296
+ padding: 12px;
5297
+ font-family: 'Syne', sans-serif;
5298
+ font-size: 15px;
5299
+ font-weight: 700;
5300
+ cursor: pointer;
5301
+ margin-top: 8px;
5302
+ transition: opacity 0.15s, transform 0.1s;
5303
+ }
5304
+ .btn:hover { opacity: 0.9; }
5305
+ .btn:active { transform: scale(0.98); }
5306
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
5307
+ .footer { margin-top: 28px; text-align: center; font-size: 12px; color: var(--text-3); font-family: 'DM Mono', monospace; }
5308
+ </style>
5309
+ </head>
5310
+ <body>
5311
+ <div class="card">
5312
+ <div class="logo">
5313
+ <div class="logo-mark">Q</div>
5314
+ <span class="logo-text">OpenQA</span>
5315
+ </div>
5316
+ <h1>Sign in</h1>
5317
+ <p class="subtitle">Access your QA dashboard</p>
5318
+
5319
+ <div class="error-msg" id="error">${error || "Invalid credentials"}</div>
5320
+
5321
+ <form id="loginForm">
5322
+ <div class="field">
5323
+ <label for="username">Username</label>
5324
+ <input type="text" id="username" name="username" autocomplete="username" autofocus required>
5325
+ </div>
5326
+ <div class="field">
5327
+ <label for="password">Password</label>
5328
+ <input type="password" id="password" name="password" autocomplete="current-password" required>
5329
+ </div>
5330
+ <button type="submit" class="btn" id="submitBtn">Sign In</button>
5331
+ </form>
5332
+
5333
+ <div class="footer">OpenQA v1.3.4</div>
5334
+ </div>
5335
+
5336
+ <script>
5337
+ const form = document.getElementById('loginForm');
5338
+ const errorEl = document.getElementById('error');
5339
+ const btn = document.getElementById('submitBtn');
5340
+
5341
+ form.addEventListener('submit', async (e) => {
5342
+ e.preventDefault();
5343
+ errorEl.style.display = 'none';
5344
+ btn.disabled = true;
5345
+ btn.textContent = 'Signing in\u2026';
5346
+ try {
5347
+ const res = await fetch('/api/auth/login', {
5348
+ method: 'POST',
5349
+ headers: { 'Content-Type': 'application/json' },
5350
+ body: JSON.stringify({
5351
+ username: document.getElementById('username').value.trim(),
5352
+ password: document.getElementById('password').value,
5353
+ }),
5354
+ credentials: 'include',
5355
+ });
5356
+ if (res.ok) {
5357
+ const params = new URLSearchParams(window.location.search);
5358
+ window.location.href = params.get('next') || '/';
5359
+ } else {
5360
+ const data = await res.json().catch(() => ({}));
5361
+ errorEl.textContent = data.error || 'Invalid credentials';
5362
+ errorEl.style.display = 'block';
5363
+ btn.disabled = false;
5364
+ btn.textContent = 'Sign In';
5365
+ }
5366
+ } catch {
5367
+ errorEl.textContent = 'Network error \u2014 please try again';
5368
+ errorEl.style.display = 'block';
5369
+ btn.disabled = false;
5370
+ btn.textContent = 'Sign In';
5371
+ }
5372
+ });
5373
+ </script>
5374
+ </body>
5375
+ </html>`;
5376
+ }
5377
+
5378
+ // cli/setup.html.ts
5379
+ init_esm_shims();
5380
+ function getSetupHTML() {
5381
+ return `<!DOCTYPE html>
5382
+ <html lang="en">
5383
+ <head>
5384
+ <meta charset="UTF-8">
5385
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
5386
+ <title>OpenQA \u2014 Setup</title>
5387
+ <link rel="preconnect" href="https://fonts.googleapis.com">
5388
+ <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@400;600;700;800&display=swap" rel="stylesheet">
5389
+ <style>
5390
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
5391
+ :root {
5392
+ --bg: #080b10;
5393
+ --surface: #0d1117;
5394
+ --panel: #111720;
5395
+ --border: rgba(255,255,255,0.06);
5396
+ --border-hi: rgba(255,255,255,0.14);
5397
+ --accent: #f97316;
5398
+ --accent-lo: rgba(249,115,22,0.08);
5399
+ --text-1: #f1f5f9;
5400
+ --text-2: #8b98a8;
5401
+ --text-3: #4b5563;
5402
+ --red: #ef4444;
5403
+ --red-lo: rgba(239,68,68,0.10);
5404
+ --green: #22c55e;
5405
+ --green-lo: rgba(34,197,94,0.08);
5406
+ }
5407
+ html, body { height: 100%; }
5408
+ body {
5409
+ background: var(--bg);
5410
+ font-family: 'Syne', sans-serif;
5411
+ color: var(--text-1);
5412
+ display: flex;
5413
+ align-items: center;
5414
+ justify-content: center;
5415
+ min-height: 100vh;
5416
+ }
5417
+ .card {
5418
+ width: 100%;
5419
+ max-width: 440px;
5420
+ background: var(--surface);
5421
+ border: 1px solid var(--border-hi);
5422
+ border-radius: 16px;
5423
+ padding: 40px 36px 36px;
5424
+ box-shadow: 0 24px 64px rgba(0,0,0,0.6);
5425
+ }
5426
+ .logo {
5427
+ display: flex; align-items: center; gap: 10px;
5428
+ margin-bottom: 28px; justify-content: center;
5429
+ }
5430
+ .logo-mark {
5431
+ width: 36px; height: 36px;
5432
+ background: var(--accent);
5433
+ border-radius: 8px;
5434
+ display: flex; align-items: center; justify-content: center;
5435
+ font-size: 18px; font-weight: 800; color: #fff;
5436
+ }
5437
+ .logo-text { font-size: 22px; font-weight: 800; letter-spacing: -0.5px; }
5438
+ h1 { font-size: 20px; font-weight: 700; text-align: center; margin-bottom: 6px; }
5439
+ .subtitle { font-size: 13px; color: var(--text-2); text-align: center; margin-bottom: 28px; }
5440
+ .badge {
5441
+ display: inline-block;
5442
+ background: var(--accent-lo);
5443
+ color: var(--accent);
5444
+ border: 1px solid rgba(249,115,22,0.25);
5445
+ border-radius: 6px;
5446
+ font-size: 11px; font-weight: 700; letter-spacing: 0.08em;
5447
+ padding: 3px 8px; margin-bottom: 20px;
5448
+ text-align: center; width: 100%;
5449
+ }
5450
+ .field { margin-bottom: 16px; }
5451
+ label { display: block; font-size: 12px; font-weight: 600; color: var(--text-2); margin-bottom: 6px; letter-spacing: 0.04em; text-transform: uppercase; }
5452
+ input[type=text], input[type=password] {
5453
+ width: 100%;
5454
+ background: var(--panel);
5455
+ border: 1px solid var(--border-hi);
5456
+ border-radius: 8px;
5457
+ padding: 10px 14px;
5458
+ font-family: 'DM Mono', monospace;
5459
+ font-size: 14px;
5460
+ color: var(--text-1);
5461
+ outline: none;
5462
+ transition: border-color 0.15s;
5463
+ }
5464
+ input:focus { border-color: var(--accent); }
5465
+ .hint { font-size: 11px; color: var(--text-3); margin-top: 5px; font-family: 'DM Mono', monospace; }
5466
+ .msg {
5467
+ border-radius: 8px;
5468
+ padding: 10px 14px;
5469
+ font-size: 13px;
5470
+ margin-bottom: 18px;
5471
+ display: none;
5472
+ }
5473
+ .msg.error { background: var(--red-lo); border: 1px solid rgba(239,68,68,0.3); color: var(--red); }
5474
+ .msg.success { background: var(--green-lo); border: 1px solid rgba(34,197,94,0.3); color: var(--green); }
5475
+ .btn {
5476
+ width: 100%;
5477
+ background: var(--accent);
5478
+ color: #fff;
5479
+ border: none;
5480
+ border-radius: 8px;
5481
+ padding: 12px;
5482
+ font-family: 'Syne', sans-serif;
5483
+ font-size: 15px; font-weight: 700;
5484
+ cursor: pointer; margin-top: 8px;
5485
+ transition: opacity 0.15s, transform 0.1s;
5486
+ }
5487
+ .btn:hover { opacity: 0.9; }
5488
+ .btn:active { transform: scale(0.98); }
5489
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
5490
+ .footer { margin-top: 28px; text-align: center; font-size: 12px; color: var(--text-3); font-family: 'DM Mono', monospace; }
5491
+ </style>
5492
+ </head>
5493
+ <body>
5494
+ <div class="card">
5495
+ <div class="logo">
5496
+ <div class="logo-mark">Q</div>
5497
+ <span class="logo-text">OpenQA</span>
5498
+ </div>
5499
+ <div class="badge">FIRST RUN</div>
5500
+ <h1>Create admin account</h1>
5501
+ <p class="subtitle">Set up your OpenQA instance</p>
5502
+
5503
+ <div class="msg error" id="error"></div>
5504
+ <div class="msg success" id="success"></div>
5505
+
5506
+ <form id="setupForm">
5507
+ <div class="field">
5508
+ <label for="username">Username or Email</label>
5509
+ <input type="text" id="username" name="username" autocomplete="username" autofocus required pattern="[a-z0-9_.@-]+" title="Lowercase letters, digits, and ._@- characters">
5510
+ <div class="hint">Use your email or a username (lowercase, digits, ._@- allowed)</div>
5511
+ </div>
5512
+ <div class="field">
5513
+ <label for="password">Password</label>
5514
+ <input type="password" id="password" name="password" autocomplete="new-password" required minlength="8">
5515
+ <div class="hint">Minimum 8 characters</div>
5516
+ </div>
5517
+ <div class="field">
5518
+ <label for="confirm">Confirm Password</label>
5519
+ <input type="password" id="confirm" name="confirm" autocomplete="new-password" required minlength="8">
5520
+ </div>
5521
+ <button type="submit" class="btn" id="submitBtn">Create Account &amp; Sign In</button>
5522
+ </form>
5523
+
5524
+ <div class="footer">OpenQA v1.3.4</div>
5525
+ </div>
5526
+
5527
+ <script>
5528
+ const form = document.getElementById('setupForm');
5529
+ const errorEl = document.getElementById('error');
5530
+ const successEl = document.getElementById('success');
5531
+ const btn = document.getElementById('submitBtn');
5532
+
5533
+ form.addEventListener('submit', async (e) => {
5534
+ e.preventDefault();
5535
+ errorEl.style.display = 'none';
5536
+ successEl.style.display = 'none';
5537
+
5538
+ const password = document.getElementById('password').value;
5539
+ const confirm = document.getElementById('confirm').value;
5540
+ if (password !== confirm) {
5541
+ errorEl.textContent = 'Passwords do not match';
5542
+ errorEl.style.display = 'block';
5543
+ return;
5544
+ }
5545
+
5546
+ btn.disabled = true;
5547
+ btn.textContent = 'Creating account\u2026';
5548
+
5549
+ try {
5550
+ const res = await fetch('/api/setup', {
5551
+ method: 'POST',
5552
+ headers: { 'Content-Type': 'application/json' },
5553
+ body: JSON.stringify({
5554
+ username: document.getElementById('username').value.trim(),
5555
+ password,
5556
+ }),
5557
+ credentials: 'include',
5558
+ });
5559
+
5560
+ if (res.ok) {
5561
+ successEl.textContent = 'Account created! Redirecting\u2026';
5562
+ successEl.style.display = 'block';
5563
+ setTimeout(() => { window.location.href = '/'; }, 800);
5564
+ } else {
5565
+ const data = await res.json().catch(() => ({}));
5566
+ errorEl.textContent = data.error || 'Setup failed';
5567
+ errorEl.style.display = 'block';
5568
+ btn.disabled = false;
5569
+ btn.textContent = 'Create Account & Sign In';
5570
+ }
5571
+ } catch {
5572
+ errorEl.textContent = 'Network error \u2014 please try again';
5573
+ errorEl.style.display = 'block';
5574
+ btn.disabled = false;
5575
+ btn.textContent = 'Create Account & Sign In';
5576
+ }
5577
+ });
5578
+ </script>
5579
+ </body>
5580
+ </html>`;
5581
+ }
5582
+
5583
+ // cli/env.html.ts
5584
+ init_esm_shims();
5585
+ function getEnvHTML() {
5586
+ return `<!DOCTYPE html>
5587
+ <html lang="en">
5588
+ <head>
5589
+ <meta charset="UTF-8">
5590
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
5591
+ <title>Environment Variables - OpenQA</title>
5592
+ <style>
5593
+ * { margin: 0; padding: 0; box-sizing: border-box; }
5594
+
5595
+ body {
5596
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
5597
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
5598
+ min-height: 100vh;
5599
+ padding: 20px;
5600
+ }
5601
+
5602
+ .container {
5603
+ max-width: 1200px;
5604
+ margin: 0 auto;
5605
+ }
5606
+
5607
+ .header {
5608
+ background: rgba(255, 255, 255, 0.95);
5609
+ backdrop-filter: blur(10px);
5610
+ padding: 20px 30px;
5611
+ border-radius: 12px;
5612
+ margin-bottom: 20px;
5613
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
5614
+ display: flex;
5615
+ justify-content: space-between;
5616
+ align-items: center;
5617
+ }
5618
+
5619
+ .header h1 {
5620
+ font-size: 24px;
5621
+ color: #1a202c;
5622
+ display: flex;
5623
+ align-items: center;
5624
+ gap: 10px;
5625
+ }
5626
+
5627
+ .header-actions {
5628
+ display: flex;
5629
+ gap: 10px;
5630
+ }
5631
+
5632
+ .btn {
5633
+ padding: 10px 20px;
5634
+ border: none;
5635
+ border-radius: 8px;
5636
+ font-size: 14px;
5637
+ font-weight: 600;
5638
+ cursor: pointer;
5639
+ transition: all 0.2s;
5640
+ text-decoration: none;
5641
+ display: inline-flex;
5642
+ align-items: center;
5643
+ gap: 8px;
5644
+ }
5645
+
5646
+ .btn-primary {
5647
+ background: #667eea;
5648
+ color: white;
5649
+ }
5650
+
5651
+ .btn-primary:hover {
5652
+ background: #5568d3;
5653
+ transform: translateY(-1px);
5654
+ }
5655
+
5656
+ .btn-secondary {
5657
+ background: #e2e8f0;
5658
+ color: #4a5568;
5659
+ }
5660
+
5661
+ .btn-secondary:hover {
5662
+ background: #cbd5e0;
5663
+ }
5664
+
5665
+ .btn-success {
5666
+ background: #48bb78;
5667
+ color: white;
5668
+ }
5669
+
5670
+ .btn-success:hover {
5671
+ background: #38a169;
5672
+ }
5673
+
5674
+ .btn:disabled {
5675
+ opacity: 0.5;
5676
+ cursor: not-allowed;
5677
+ }
5678
+
5679
+ .content {
5680
+ background: rgba(255, 255, 255, 0.95);
5681
+ backdrop-filter: blur(10px);
5682
+ padding: 30px;
5683
+ border-radius: 12px;
5684
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
5685
+ }
5686
+
5687
+ .tabs {
5688
+ display: flex;
5689
+ gap: 10px;
5690
+ margin-bottom: 30px;
5691
+ border-bottom: 2px solid #e2e8f0;
5692
+ padding-bottom: 10px;
5693
+ }
5694
+
5695
+ .tab {
5696
+ padding: 10px 20px;
5697
+ border: none;
5698
+ background: none;
5699
+ font-size: 14px;
5700
+ font-weight: 600;
5701
+ color: #718096;
5702
+ cursor: pointer;
5703
+ border-bottom: 3px solid transparent;
5704
+ transition: all 0.2s;
5705
+ }
5706
+
5707
+ .tab.active {
5708
+ color: #667eea;
5709
+ border-bottom-color: #667eea;
5710
+ }
5711
+
5712
+ .tab:hover {
5713
+ color: #667eea;
5714
+ }
5715
+
5716
+ .category-section {
5717
+ display: none;
5718
+ }
5719
+
5720
+ .category-section.active {
5721
+ display: block;
5722
+ }
5723
+
5724
+ .category-header {
5725
+ display: flex;
5726
+ justify-content: space-between;
5727
+ align-items: center;
5728
+ margin-bottom: 20px;
5729
+ }
5730
+
5731
+ .category-title {
5732
+ font-size: 18px;
5733
+ font-weight: 600;
5734
+ color: #2d3748;
5735
+ }
5736
+
5737
+ .env-grid {
5738
+ display: grid;
5739
+ gap: 20px;
5740
+ }
5741
+
5742
+ .env-item {
5743
+ border: 1px solid #e2e8f0;
5744
+ border-radius: 8px;
5745
+ padding: 20px;
5746
+ transition: all 0.2s;
5747
+ }
5748
+
5749
+ .env-item:hover {
5750
+ border-color: #cbd5e0;
5751
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
5752
+ }
5753
+
5754
+ .env-item-header {
5755
+ display: flex;
5756
+ justify-content: space-between;
5757
+ align-items: flex-start;
5758
+ margin-bottom: 10px;
5759
+ }
5760
+
5761
+ .env-label {
5762
+ font-weight: 600;
5763
+ color: #2d3748;
5764
+ font-size: 14px;
5765
+ display: flex;
5766
+ align-items: center;
5767
+ gap: 8px;
5768
+ }
5769
+
5770
+ .required-badge {
5771
+ background: #fc8181;
5772
+ color: white;
5773
+ font-size: 10px;
5774
+ padding: 2px 6px;
5775
+ border-radius: 4px;
5776
+ font-weight: 700;
5777
+ }
5778
+
5779
+ .env-description {
5780
+ font-size: 13px;
5781
+ color: #718096;
5782
+ margin-bottom: 10px;
5783
+ }
5784
+
5785
+ .env-input-group {
5786
+ display: flex;
5787
+ gap: 10px;
5788
+ align-items: center;
5789
+ }
5790
+
5791
+ .env-input {
5792
+ flex: 1;
5793
+ padding: 10px 12px;
5794
+ border: 1px solid #e2e8f0;
5795
+ border-radius: 6px;
5796
+ font-size: 14px;
5797
+ font-family: 'Monaco', 'Courier New', monospace;
5798
+ transition: all 0.2s;
5799
+ }
5800
+
5801
+ .env-input:focus {
5802
+ outline: none;
5803
+ border-color: #667eea;
5804
+ box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
5805
+ }
5806
+
5807
+ .env-input.error {
5808
+ border-color: #fc8181;
5809
+ }
5810
+
5811
+ .env-actions {
5812
+ display: flex;
5813
+ gap: 5px;
5814
+ }
5815
+
5816
+ .icon-btn {
5817
+ padding: 8px;
5818
+ border: none;
5819
+ background: #e2e8f0;
5820
+ border-radius: 6px;
5821
+ cursor: pointer;
5822
+ transition: all 0.2s;
5823
+ font-size: 16px;
5824
+ }
5825
+
5826
+ .icon-btn:hover {
5827
+ background: #cbd5e0;
5828
+ }
5829
+
5830
+ .icon-btn.test {
5831
+ background: #bee3f8;
5832
+ color: #2c5282;
5833
+ }
5834
+
5835
+ .icon-btn.test:hover {
5836
+ background: #90cdf4;
5837
+ }
5838
+
5839
+ .icon-btn.generate {
5840
+ background: #c6f6d5;
5841
+ color: #22543d;
5842
+ }
5843
+
5844
+ .icon-btn.generate:hover {
5845
+ background: #9ae6b4;
5846
+ }
5847
+
5848
+ .error-message {
5849
+ color: #e53e3e;
5850
+ font-size: 12px;
5851
+ margin-top: 5px;
5852
+ }
5853
+
5854
+ .success-message {
5855
+ color: #38a169;
5856
+ font-size: 12px;
5857
+ margin-top: 5px;
5858
+ }
5859
+
5860
+ .alert {
5861
+ padding: 15px 20px;
5862
+ border-radius: 8px;
5863
+ margin-bottom: 20px;
5864
+ display: flex;
5865
+ align-items: center;
5866
+ gap: 10px;
5867
+ }
5868
+
5869
+ .alert-warning {
5870
+ background: #fef5e7;
5871
+ border-left: 4px solid #f59e0b;
5872
+ color: #92400e;
5873
+ }
5874
+
5875
+ .alert-info {
5876
+ background: #eff6ff;
5877
+ border-left: 4px solid #3b82f6;
5878
+ color: #1e40af;
5879
+ }
5880
+
5881
+ .alert-success {
5882
+ background: #f0fdf4;
5883
+ border-left: 4px solid #10b981;
5884
+ color: #065f46;
5885
+ }
5886
+
5887
+ .loading {
5888
+ text-align: center;
5889
+ padding: 40px;
5890
+ color: #718096;
5891
+ }
5892
+
5893
+ .spinner {
5894
+ border: 3px solid #e2e8f0;
5895
+ border-top: 3px solid #667eea;
5896
+ border-radius: 50%;
5897
+ width: 40px;
5898
+ height: 40px;
5899
+ animation: spin 1s linear infinite;
5900
+ margin: 0 auto 20px;
5901
+ }
5902
+
5903
+ @keyframes spin {
5904
+ 0% { transform: rotate(0deg); }
5905
+ 100% { transform: rotate(360deg); }
5906
+ }
5907
+
5908
+ .modal {
5909
+ display: none;
5910
+ position: fixed;
5911
+ top: 0;
5912
+ left: 0;
5913
+ right: 0;
5914
+ bottom: 0;
5915
+ background: rgba(0, 0, 0, 0.5);
5916
+ z-index: 1000;
5917
+ align-items: center;
5918
+ justify-content: center;
5919
+ }
5920
+
5921
+ .modal.show {
5922
+ display: flex;
5923
+ }
5924
+
5925
+ .modal-content {
5926
+ background: white;
5927
+ padding: 30px;
5928
+ border-radius: 12px;
5929
+ max-width: 500px;
5930
+ width: 90%;
5931
+ box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
5932
+ }
5933
+
5934
+ .modal-header {
5935
+ font-size: 20px;
5936
+ font-weight: 600;
5937
+ margin-bottom: 15px;
5938
+ color: #2d3748;
5939
+ }
5940
+
5941
+ .modal-body {
5942
+ margin-bottom: 20px;
5943
+ color: #4a5568;
5944
+ }
5945
+
5946
+ .modal-footer {
5947
+ display: flex;
5948
+ gap: 10px;
5949
+ justify-content: flex-end;
5950
+ }
5951
+ </style>
5952
+ </head>
5953
+ <body>
5954
+ <div class="container">
5955
+ <div class="header">
5956
+ <h1>
5957
+ <span>\u2699\uFE0F</span>
5958
+ Environment Variables
5959
+ </h1>
5960
+ <div class="header-actions">
5961
+ <a href="/config" class="btn btn-secondary">\u2190 Back to Config</a>
5962
+ <button id="saveBtn" class="btn btn-success" disabled>\u{1F4BE} Save Changes</button>
5963
+ </div>
5964
+ </div>
5965
+
5966
+ <div class="content">
5967
+ <div id="loading" class="loading">
5968
+ <div class="spinner"></div>
5969
+ <div>Loading environment variables...</div>
5970
+ </div>
5971
+
5972
+ <div id="main" style="display: none;">
5973
+ <div id="alerts"></div>
5974
+
5975
+ <div class="tabs">
5976
+ <button class="tab active" data-category="llm">\u{1F916} LLM</button>
5977
+ <button class="tab" data-category="security">\u{1F512} Security</button>
5978
+ <button class="tab" data-category="target">\u{1F3AF} Target App</button>
5979
+ <button class="tab" data-category="github">\u{1F419} GitHub</button>
5980
+ <button class="tab" data-category="web">\u{1F310} Web Server</button>
5981
+ <button class="tab" data-category="agent">\u{1F916} Agent</button>
5982
+ <button class="tab" data-category="database">\u{1F4BE} Database</button>
5983
+ <button class="tab" data-category="notifications">\u{1F514} Notifications</button>
5984
+ </div>
5985
+
5986
+ <div id="categories"></div>
5987
+ </div>
5988
+ </div>
5989
+ </div>
5990
+
5991
+ <!-- Test Result Modal -->
5992
+ <div id="testModal" class="modal">
5993
+ <div class="modal-content">
5994
+ <div class="modal-header">Test Result</div>
5995
+ <div class="modal-body" id="testResult"></div>
5996
+ <div class="modal-footer">
5997
+ <button class="btn btn-secondary" onclick="closeTestModal()">Close</button>
5998
+ </div>
5999
+ </div>
6000
+ </div>
6001
+
6002
+ <script>
6003
+ let envVariables = [];
6004
+ let changedVariables = {};
6005
+ let restartRequired = false;
6006
+
6007
+ // Load environment variables
6008
+ async function loadEnvVariables() {
6009
+ try {
6010
+ const response = await fetch('/api/env');
6011
+ if (!response.ok) throw new Error('Failed to load variables');
6012
+
6013
+ const data = await response.json();
6014
+ envVariables = data.variables;
6015
+
6016
+ renderCategories();
6017
+ document.getElementById('loading').style.display = 'none';
6018
+ document.getElementById('main').style.display = 'block';
6019
+ } catch (error) {
6020
+ showAlert('error', 'Failed to load environment variables: ' + error.message);
6021
+ }
6022
+ }
6023
+
6024
+ // Render categories
6025
+ function renderCategories() {
6026
+ const container = document.getElementById('categories');
6027
+ const categories = [...new Set(envVariables.map(v => v.category))];
6028
+
6029
+ categories.forEach((category, index) => {
6030
+ const section = document.createElement('div');
6031
+ section.className = 'category-section' + (index === 0 ? ' active' : '');
6032
+ section.dataset.category = category;
6033
+
6034
+ const vars = envVariables.filter(v => v.category === category);
6035
+
6036
+ section.innerHTML = \`
6037
+ <div class="category-header">
6038
+ <div class="category-title">\${getCategoryTitle(category)}</div>
6039
+ </div>
6040
+ <div class="env-grid">
6041
+ \${vars.map(v => renderEnvItem(v)).join('')}
6042
+ </div>
6043
+ \`;
6044
+
6045
+ container.appendChild(section);
6046
+ });
6047
+ }
6048
+
6049
+ // Render single env item
6050
+ function renderEnvItem(envVar) {
6051
+ const inputType = envVar.type === 'password' ? 'password' : 'text';
6052
+ const value = envVar.displayValue || '';
6053
+
6054
+ return \`
6055
+ <div class="env-item" data-key="\${envVar.key}">
6056
+ <div class="env-item-header">
6057
+ <div class="env-label">
6058
+ \${envVar.key}
6059
+ \${envVar.required ? '<span class="required-badge">REQUIRED</span>' : ''}
6060
+ </div>
6061
+ </div>
6062
+ <div class="env-description">\${envVar.description}</div>
6063
+ <div class="env-input-group">
6064
+ \${envVar.type === 'select' ?
6065
+ \`<select class="env-input" data-key="\${envVar.key}" onchange="handleChange(this)">
6066
+ <option value="">-- Select --</option>
6067
+ \${envVar.options.map(opt =>
6068
+ \`<option value="\${opt}" \${value === opt ? 'selected' : ''}>\${opt}</option>\`
6069
+ ).join('')}
6070
+ </select>\` :
6071
+ envVar.type === 'boolean' ?
6072
+ \`<select class="env-input" data-key="\${envVar.key}" onchange="handleChange(this)">
6073
+ <option value="">-- Select --</option>
6074
+ <option value="true" \${value === 'true' ? 'selected' : ''}>true</option>
6075
+ <option value="false" \${value === 'false' ? 'selected' : ''}>false</option>
6076
+ </select>\` :
6077
+ \`<input
6078
+ type="\${inputType}"
6079
+ class="env-input"
6080
+ data-key="\${envVar.key}"
6081
+ value="\${value}"
6082
+ placeholder="\${envVar.placeholder || ''}"
6083
+ onchange="handleChange(this)"
6084
+ />\`
6085
+ }
6086
+ <div class="env-actions">
6087
+ \${envVar.testable ? \`<button class="icon-btn test" onclick="testVariable('\${envVar.key}')" title="Test">\u{1F9EA}</button>\` : ''}
6088
+ \${envVar.key === 'OPENQA_JWT_SECRET' ? \`<button class="icon-btn generate" onclick="generateSecret('\${envVar.key}')" title="Generate">\u{1F511}</button>\` : ''}
6089
+ </div>
6090
+ </div>
6091
+ <div class="error-message" id="error-\${envVar.key}"></div>
6092
+ <div class="success-message" id="success-\${envVar.key}"></div>
6093
+ </div>
6094
+ \`;
6095
+ }
6096
+
6097
+ // Handle input change
6098
+ function handleChange(input) {
6099
+ const key = input.dataset.key;
6100
+ const value = input.value;
6101
+
6102
+ changedVariables[key] = value;
6103
+ document.getElementById('saveBtn').disabled = false;
6104
+
6105
+ // Clear messages
6106
+ document.getElementById(\`error-\${key}\`).textContent = '';
6107
+ document.getElementById(\`success-\${key}\`).textContent = '';
6108
+ }
6109
+
6110
+ // Save changes
6111
+ async function saveChanges() {
6112
+ const saveBtn = document.getElementById('saveBtn');
6113
+ saveBtn.disabled = true;
6114
+ saveBtn.textContent = '\u{1F4BE} Saving...';
6115
+
6116
+ try {
6117
+ const response = await fetch('/api/env/bulk', {
6118
+ method: 'POST',
6119
+ headers: { 'Content-Type': 'application/json' },
6120
+ body: JSON.stringify({ variables: changedVariables }),
6121
+ });
6122
+
6123
+ if (!response.ok) {
6124
+ const error = await response.json();
6125
+ throw new Error(error.error || 'Failed to save');
6126
+ }
6127
+
6128
+ const result = await response.json();
6129
+ restartRequired = result.restartRequired;
6130
+
6131
+ showAlert('success', \`\u2705 Saved \${result.updated} variable(s) successfully!\` +
6132
+ (restartRequired ? ' \u26A0\uFE0F Restart required for changes to take effect.' : ''));
6133
+
6134
+ changedVariables = {};
6135
+ saveBtn.textContent = '\u{1F4BE} Save Changes';
6136
+
6137
+ // Reload to show updated values
6138
+ setTimeout(() => location.reload(), 2000);
6139
+ } catch (error) {
6140
+ showAlert('error', 'Failed to save: ' + error.message);
6141
+ saveBtn.disabled = false;
6142
+ saveBtn.textContent = '\u{1F4BE} Save Changes';
6143
+ }
6144
+ }
6145
+
6146
+ // Test variable
6147
+ async function testVariable(key) {
6148
+ const input = document.querySelector(\`[data-key="\${key}"]\`);
6149
+ const value = input.value;
6150
+
6151
+ if (!value) {
6152
+ showAlert('warning', 'Please enter a value first');
6153
+ return;
6154
+ }
6155
+
6156
+ try {
6157
+ const response = await fetch(\`/api/env/test/\${key}\`, {
6158
+ method: 'POST',
6159
+ headers: { 'Content-Type': 'application/json' },
6160
+ body: JSON.stringify({ value }),
6161
+ });
6162
+
6163
+ const result = await response.json();
6164
+ showTestResult(result);
6165
+ } catch (error) {
6166
+ showTestResult({ success: false, message: 'Test failed: ' + error.message });
6167
+ }
6168
+ }
6169
+
6170
+ // Generate secret
6171
+ async function generateSecret(key) {
6172
+ try {
6173
+ const response = await fetch(\`/api/env/generate/\${key}\`, {
6174
+ method: 'POST',
6175
+ });
6176
+
6177
+ if (!response.ok) throw new Error('Failed to generate');
6178
+
6179
+ const result = await response.json();
6180
+ const input = document.querySelector(\`[data-key="\${key}"]\`);
6181
+ input.value = result.value;
6182
+ handleChange(input);
6183
+
6184
+ document.getElementById(\`success-\${key}\`).textContent = '\u2705 Secret generated!';
6185
+ } catch (error) {
6186
+ document.getElementById(\`error-\${key}\`).textContent = 'Failed to generate: ' + error.message;
6187
+ }
6188
+ }
6189
+
6190
+ // Show test result
6191
+ function showTestResult(result) {
6192
+ const modal = document.getElementById('testModal');
6193
+ const resultDiv = document.getElementById('testResult');
6194
+
6195
+ resultDiv.innerHTML = \`
6196
+ <div class="alert \${result.success ? 'alert-success' : 'alert-warning'}">
6197
+ \${result.success ? '\u2705' : '\u274C'} \${result.message}
6198
+ </div>
6199
+ \`;
6200
+
6201
+ modal.classList.add('show');
6202
+ }
6203
+
6204
+ function closeTestModal() {
6205
+ document.getElementById('testModal').classList.remove('show');
6206
+ }
6207
+
6208
+ // Show alert
6209
+ function showAlert(type, message) {
6210
+ const alerts = document.getElementById('alerts');
6211
+ const alertClass = type === 'error' ? 'alert-warning' :
6212
+ type === 'success' ? 'alert-success' : 'alert-info';
6213
+
6214
+ alerts.innerHTML = \`
6215
+ <div class="alert \${alertClass}">
6216
+ \${message}
6217
+ </div>
6218
+ \`;
6219
+
6220
+ setTimeout(() => alerts.innerHTML = '', 5000);
6221
+ }
6222
+
6223
+ // Get category title
6224
+ function getCategoryTitle(category) {
6225
+ const titles = {
6226
+ llm: '\u{1F916} LLM Configuration',
6227
+ security: '\u{1F512} Security Settings',
6228
+ target: '\u{1F3AF} Target Application',
6229
+ github: '\u{1F419} GitHub Integration',
6230
+ web: '\u{1F310} Web Server',
6231
+ agent: '\u{1F916} Agent Configuration',
6232
+ database: '\u{1F4BE} Database',
6233
+ notifications: '\u{1F514} Notifications',
6234
+ };
6235
+ return titles[category] || category;
6236
+ }
6237
+
6238
+ // Tab switching
6239
+ document.addEventListener('click', (e) => {
6240
+ if (e.target.classList.contains('tab')) {
6241
+ const category = e.target.dataset.category;
6242
+
6243
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
6244
+ e.target.classList.add('active');
6245
+
6246
+ document.querySelectorAll('.category-section').forEach(s => s.classList.remove('active'));
6247
+ document.querySelector(\`[data-category="\${category}"]\`).classList.add('active');
6248
+ }
6249
+ });
6250
+
6251
+ // Save button
6252
+ document.getElementById('saveBtn').addEventListener('click', saveChanges);
6253
+
6254
+ // Load on page load
6255
+ loadEnvVariables();
6256
+ </script>
6257
+ </body>
6258
+ </html>`;
6259
+ }
6260
+
4118
6261
  // cli/server.ts
4119
6262
  import chalk from "chalk";
6263
+ import rateLimit from "express-rate-limit";
6264
+ import cors from "cors";
4120
6265
  async function startWebServer() {
4121
6266
  const config = new ConfigManager();
4122
6267
  const cfg = config.getConfigSync();
4123
6268
  const db = new OpenQADatabase("./data/openqa.json");
4124
6269
  const app = express();
4125
- app.use(express.json());
6270
+ const allowedOrigins = (process.env.CORS_ORIGINS || `http://localhost:${cfg.web.port}`).split(",");
6271
+ app.use(cors({
6272
+ origin: (origin, cb) => {
6273
+ if (!origin || allowedOrigins.includes(origin) || allowedOrigins.includes("*")) {
6274
+ cb(null, true);
6275
+ } else {
6276
+ cb(new Error(`CORS: origin ${origin} not allowed`));
6277
+ }
6278
+ },
6279
+ credentials: true
6280
+ }));
6281
+ app.use(express.json({ limit: "1mb" }));
6282
+ const apiLimiter = rateLimit({
6283
+ windowMs: 6e4,
6284
+ max: 300,
6285
+ standardHeaders: true,
6286
+ legacyHeaders: false,
6287
+ message: { error: "Too many requests, please try again later." }
6288
+ });
6289
+ app.use("/api/", apiLimiter);
6290
+ const mutationLimiter = rateLimit({
6291
+ windowMs: 6e4,
6292
+ max: 30,
6293
+ standardHeaders: true,
6294
+ legacyHeaders: false,
6295
+ message: { error: "Too many requests, please slow down." }
6296
+ });
6297
+ app.use(["/api/start", "/api/stop", "/api/auth/login"], mutationLimiter);
4126
6298
  const wss = new WebSocketServer({ noServer: true });
6299
+ app.use(createAuthRouter(db));
6300
+ app.use(createEnvRouter());
6301
+ app.use("/api", (req, res, next) => {
6302
+ const PUBLIC_PATHS = ["/auth/login", "/auth/logout", "/setup", "/health"];
6303
+ if (PUBLIC_PATHS.some((p) => req.path === p || req.path.startsWith(p + "/"))) return next();
6304
+ return requireAuth(req, res, next);
6305
+ });
4127
6306
  app.use(createApiRouter(db, config));
4128
6307
  app.post("/api/intervention/:id", async (req, res) => {
4129
6308
  const { id } = req.params;
@@ -4202,15 +6381,28 @@ async function startWebServer() {
4202
6381
  res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
4203
6382
  }
4204
6383
  });
4205
- app.get("/", (req, res) => {
6384
+ app.get("/login", async (_req, res) => {
6385
+ const count = await db.countUsers();
6386
+ if (count === 0) return res.redirect("/setup");
6387
+ res.send(getLoginHTML());
6388
+ });
6389
+ app.get("/setup", async (_req, res) => {
6390
+ const count = await db.countUsers();
6391
+ if (count > 0) return res.redirect("/login");
6392
+ res.send(getSetupHTML());
6393
+ });
6394
+ app.get("/", authOrRedirect(db), (_req, res) => {
4206
6395
  res.send(getDashboardHTML());
4207
6396
  });
4208
- app.get("/kanban", (req, res) => {
6397
+ app.get("/kanban", authOrRedirect(db), (_req, res) => {
4209
6398
  res.send(getKanbanHTML());
4210
6399
  });
4211
- app.get("/config", (req, res) => {
6400
+ app.get("/config", authOrRedirect(db), (_req, res) => {
4212
6401
  res.send(getConfigHTML(config.getConfigSync()));
4213
6402
  });
6403
+ app.get("/config/env", authOrRedirect(db), (_req, res) => {
6404
+ res.send(getEnvHTML());
6405
+ });
4214
6406
  const server = app.listen(cfg.web.port, cfg.web.host, () => {
4215
6407
  console.log(chalk.cyan("\n\u{1F4CA} OpenQA Status:"));
4216
6408
  console.log(chalk.white(` Agent: ${cfg.agent.autoStart ? "Auto-start enabled" : "Idle"}`));
@@ -4218,6 +6410,7 @@ async function startWebServer() {
4218
6410
  console.log(chalk.white(` Dashboard: http://localhost:${cfg.web.port}`));
4219
6411
  console.log(chalk.white(` Kanban: http://localhost:${cfg.web.port}/kanban`));
4220
6412
  console.log(chalk.white(` Config: http://localhost:${cfg.web.port}/config`));
6413
+ console.log(chalk.white(` Env Variables: http://localhost:${cfg.web.port}/config/env`));
4221
6414
  console.log(chalk.gray("\nPress Ctrl+C to stop\n"));
4222
6415
  if (!cfg.agent.autoStart) {
4223
6416
  console.log(chalk.yellow("\u{1F4A1} Auto-start disabled. Agent is idle."));