From 720bc1826b1133c6ae0f2d0d22bc2c9aa9217a25 Mon Sep 17 00:00:00 2001
From: Devarshi Shimpi
Date: Tue, 2 Jun 2026 22:14:26 +0530
Subject: [PATCH 1/7] add: automated cf setup configuration script
---
package-lock.json | 328 ++++++++++++++++++++--
package.json | 4 +
scripts/setup-cloudflare.js | 543 ++++++++++++++++++++++++++++++++++++
3 files changed, 854 insertions(+), 21 deletions(-)
create mode 100644 scripts/setup-cloudflare.js
diff --git a/package-lock.json b/package-lock.json
index a9e72f9..9909c30 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -47,9 +47,12 @@
"@vitejs/plugin-react": "^6.0.1",
"@vitest/browser": "^4.1.4",
"@vitest/browser-playwright": "^4.1.4",
+ "chalk": "^5.6.2",
"concurrently": "^9.2.1",
"jsdom": "^29.0.2",
+ "ora": "^9.4.0",
"playwright": "^1.59.1",
+ "prompts": "^2.4.2",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2",
"vite": "^8.0.8",
@@ -3711,35 +3714,18 @@
}
},
"node_modules/chalk": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
- "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "version": "5.6.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
+ "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "ansi-styles": "^4.1.0",
- "supports-color": "^7.1.0"
- },
"engines": {
- "node": ">=10"
+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
- "node_modules/chalk/node_modules/supports-color": {
- "version": "7.2.0",
- "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
- "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "has-flag": "^4.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/character-entities": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
@@ -3799,6 +3785,35 @@
"url": "https://polar.sh/cva"
}
},
+ "node_modules/cli-cursor": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
+ "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "restore-cursor": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cli-spinners": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz",
+ "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -3878,6 +3893,36 @@
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
+ "node_modules/concurrently/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/concurrently/node_modules/chalk/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -4358,6 +4403,19 @@
"node": "6.* || 8.* || >= 10.*"
}
},
+ "node_modules/get-east-asian-width": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
+ "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
@@ -4688,6 +4746,19 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/is-interactive": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz",
+ "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/is-plain-obj": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
@@ -4707,6 +4778,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/is-unicode-supported": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz",
+ "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -5057,6 +5141,23 @@
"url": "https://opencollective.com/parcel"
}
},
+ "node_modules/log-symbols": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz",
+ "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-unicode-supported": "^2.0.0",
+ "yoctocolors": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/longest-streak": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
@@ -5956,6 +6057,19 @@
],
"license": "MIT"
},
+ "node_modules/mimic-function": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
+ "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
@@ -6065,6 +6179,91 @@
],
"license": "MIT"
},
+ "node_modules/onetime": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
+ "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mimic-function": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/ora": {
+ "version": "9.4.0",
+ "resolved": "https://registry.npmjs.org/ora/-/ora-9.4.0.tgz",
+ "integrity": "sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "^5.6.2",
+ "cli-cursor": "^5.0.0",
+ "cli-spinners": "^3.2.0",
+ "is-interactive": "^2.0.0",
+ "is-unicode-supported": "^2.1.0",
+ "log-symbols": "^7.0.1",
+ "stdin-discarder": "^0.3.2",
+ "string-width": "^8.1.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/ora/node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/ora/node_modules/string-width": {
+ "version": "8.2.1",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz",
+ "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-east-asian-width": "^1.5.0",
+ "strip-ansi": "^7.1.2"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/ora/node_modules/strip-ansi": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.2.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
"node_modules/parse-entities": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
@@ -6255,6 +6454,30 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/prompts": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
+ "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "kleur": "^3.0.3",
+ "sisteransi": "^1.0.5"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/prompts/node_modules/kleur": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
+ "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/property-information": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
@@ -6640,6 +6863,23 @@
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
+ "node_modules/restore-cursor": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
+ "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "onetime": "^7.0.0",
+ "signal-exit": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/rolldown": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz",
@@ -6847,6 +7087,19 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/sirv": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
@@ -6862,6 +7115,13 @@
"node": ">=18"
}
},
+ "node_modules/sisteransi": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
+ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
@@ -6906,6 +7166,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/stdin-discarder": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz",
+ "integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -7989,6 +8262,19 @@
"node": ">=12"
}
},
+ "node_modules/yoctocolors": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz",
+ "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/youch": {
"version": "4.1.0-beta.10",
"resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz",
diff --git a/package.json b/package.json
index 0f41312..8cb7355 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"dev:client": "vite build --watch --mode development",
"dev:worker": "wrangler dev --local",
"start": "npm run dev",
+ "setup:cloudflare": "node scripts/setup-cloudflare.js",
"migrate": "node scripts/migrate.mjs",
"test": "node scripts/test.mjs",
"test:watch": "vitest",
@@ -40,9 +41,12 @@
"@vitejs/plugin-react": "^6.0.1",
"@vitest/browser": "^4.1.4",
"@vitest/browser-playwright": "^4.1.4",
+ "chalk": "^5.6.2",
"concurrently": "^9.2.1",
"jsdom": "^29.0.2",
+ "ora": "^9.4.0",
"playwright": "^1.59.1",
+ "prompts": "^2.4.2",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2",
"vite": "^8.0.8",
diff --git a/scripts/setup-cloudflare.js b/scripts/setup-cloudflare.js
new file mode 100644
index 0000000..11dfe2a
--- /dev/null
+++ b/scripts/setup-cloudflare.js
@@ -0,0 +1,543 @@
+import { exec, spawn } from 'node:child_process';
+import util from 'node:util';
+import fs from 'node:fs';
+import path from 'node:path';
+import os from 'node:os';
+import chalk from 'chalk';
+import ora from 'ora';
+import prompts from 'prompts';
+
+const execAsync = util.promisify(exec);
+
+const WRANGLER_JSONC_PATH = path.join(process.cwd(), 'wrangler.jsonc');
+const DEV_VARS_PATH = path.join(process.cwd(), '.dev.vars');
+
+async function runWranglerCmd(cmd, spinnerMessage) {
+ const spinner = ora(spinnerMessage).start();
+ try {
+ const { stdout } = await execAsync(cmd);
+ spinner.succeed();
+ return stdout;
+ } catch (error) {
+ spinner.fail();
+ console.error(chalk.red(`\nโ Error executing: ${cmd}`));
+ const errorMsg = error.stderr || error.message;
+ console.error(chalk.red(errorMsg));
+
+ if (errorMsg.includes('[code: 10000]') || errorMsg.includes('Authentication error')) {
+ console.log(chalk.yellow('\n๐ก Hint: Alternatively, run `npx wrangler login` to use your global Cloudflare session instead.'));
+ }
+ process.exit(1);
+ }
+}
+
+function extractId(output) {
+ const match = output.match(/[a-f0-9]{32}/);
+ return match ? match[0] : null;
+}
+
+async function handleKVNamespace(baseBinding, isPreview) {
+ const previewFlag = isPreview ? ' --preview' : '';
+ let currentBinding = baseBinding;
+
+ while (true) {
+ const spinner = ora(`Creating ${isPreview ? 'preview' : 'production'} KV namespace (${currentBinding})...`).start();
+ try {
+ const { stdout } = await execAsync(`npx wrangler kv namespace create ${currentBinding}${previewFlag}`);
+ spinner.succeed();
+ return extractId(stdout);
+ } catch (error) {
+ const errorMsg = error.stderr || error.message;
+ if (errorMsg.includes('already exists')) {
+ spinner.warn(`${isPreview ? 'Preview' : 'Production'} KV namespace for "${currentBinding}" already exists.`);
+
+ const { action } = await prompts({
+ type: 'select',
+ name: 'action',
+ message: `How would you like to handle this existing namespace?`,
+ choices: [
+ { title: 'Auto-fetch existing ID', value: 'fetch' },
+ { title: 'Manually enter ID', value: 'manual' },
+ { title: 'Create new with different name', value: 'new' },
+ { title: 'Skip', value: 'skip' }
+ ]
+ }, { onCancel: () => process.exit(1) });
+
+ if (action === 'fetch') {
+ const fetchSpinner = ora('Fetching existing KV namespaces...').start();
+ try {
+ const { stdout: listOut } = await execAsync('npx wrangler kv namespace list');
+ fetchSpinner.succeed();
+
+ let searchTitle = isPreview ? `${baseBinding}_preview` : baseBinding;
+ let parsed = null;
+ try {
+ const jsonStr = listOut.substring(listOut.indexOf('['), listOut.lastIndexOf(']') + 1);
+ parsed = JSON.parse(jsonStr);
+ } catch(e) {}
+
+ if (parsed && Array.isArray(parsed)) {
+ const found = parsed.find(ns => ns.title.includes(searchTitle));
+ if (found) {
+ console.log(chalk.green(` โ
Found existing ID: ${found.id}`));
+ return found.id;
+ }
+ }
+
+ console.log(chalk.yellow(` โ ๏ธ Could not automatically find an ID matching ${searchTitle}.`));
+ const { manualId } = await prompts({ type: 'text', name: 'manualId', message: 'Enter the KV Namespace ID manually:'}, { onCancel: () => process.exit(1) });
+ if (manualId) return manualId;
+ return null;
+ } catch(e) {
+ fetchSpinner.fail('Failed to fetch KV namespaces.');
+ const { manualId } = await prompts({ type: 'text', name: 'manualId', message: 'Enter the KV Namespace ID manually:'}, { onCancel: () => process.exit(1) });
+ if (manualId) return manualId;
+ return null;
+ }
+ } else if (action === 'manual') {
+ const { manualId } = await prompts({ type: 'text', name: 'manualId', message: 'Enter the KV Namespace ID:'}, { onCancel: () => process.exit(1) });
+ if (manualId) return manualId;
+ return null;
+ } else if (action === 'new') {
+ const { newName } = await prompts({ type: 'text', name: 'newName', message: 'Enter a new binding name (e.g. APP_KV_2):', initial: `${currentBinding}_2`}, { onCancel: () => process.exit(1) });
+ if (newName) {
+ currentBinding = newName;
+ continue;
+ }
+ return null;
+ } else {
+ return null;
+ }
+ } else {
+ spinner.fail();
+ console.error(chalk.red(`\nโ Error executing KV creation.`));
+ console.error(chalk.red(errorMsg));
+ if (errorMsg.includes('[code: 10000]') || errorMsg.includes('Authentication error')) {
+ console.log(chalk.yellow('\n๐ก Hint: Alternatively, run `npx wrangler login` to use your global Cloudflare session instead.'));
+ }
+ process.exit(1);
+ }
+ }
+ }
+}
+
+async function handleHyperdrive(dbUrl) {
+ let currentBinding = 'codra-db';
+
+ while (true) {
+ const spinner = ora(`Creating Hyperdrive (${currentBinding})...`).start();
+ try {
+ const { stdout } = await execAsync(`npx wrangler hyperdrive create ${currentBinding} --connection-string="${dbUrl}"`);
+ spinner.succeed();
+ return extractId(stdout);
+ } catch (error) {
+ const errorMsg = error.stderr || error.message;
+ if (errorMsg.includes('already exists') || errorMsg.includes('code: 2017')) {
+ spinner.warn(`Hyperdrive config "${currentBinding}" already exists.`);
+
+ const { action } = await prompts({
+ type: 'select',
+ name: 'action',
+ message: `How would you like to handle this existing Hyperdrive?`,
+ choices: [
+ { title: 'Auto-fetch existing ID', value: 'fetch' },
+ { title: 'Manually enter ID', value: 'manual' },
+ { title: 'Create new with different name', value: 'new' },
+ { title: 'Skip', value: 'skip' }
+ ]
+ }, { onCancel: () => process.exit(1) });
+
+ if (action === 'fetch') {
+ const fetchSpinner = ora('Fetching existing Hyperdrive configs...').start();
+ try {
+ const { stdout: listOut } = await execAsync('npx wrangler hyperdrive list');
+ fetchSpinner.succeed();
+
+ let parsed = null;
+ try {
+ const jsonStr = listOut.substring(listOut.indexOf('['), listOut.lastIndexOf(']') + 1);
+ parsed = JSON.parse(jsonStr);
+ } catch(e) {}
+
+ if (parsed && Array.isArray(parsed)) {
+ const found = parsed.find(hd => hd.name === currentBinding);
+ if (found) {
+ console.log(chalk.green(` โ
Found existing ID: ${found.id}`));
+ return found.id;
+ }
+ } else {
+ const lines = listOut.split('\n');
+ for (const line of lines) {
+ if (line.includes(currentBinding)) {
+ const match = line.match(/[a-f0-9]{32}/);
+ if (match) {
+ console.log(chalk.green(` โ
Found existing ID: ${match[0]}`));
+ return match[0];
+ }
+ }
+ }
+ }
+
+ console.log(chalk.yellow(` โ ๏ธ Could not automatically find an ID matching ${currentBinding}.`));
+ const { manualId } = await prompts({ type: 'text', name: 'manualId', message: 'Enter the Hyperdrive ID manually:'}, { onCancel: () => process.exit(1) });
+ if (manualId) return manualId;
+ return null;
+ } catch(e) {
+ fetchSpinner.fail('Failed to fetch Hyperdrive configs.');
+ const { manualId } = await prompts({ type: 'text', name: 'manualId', message: 'Enter the Hyperdrive ID manually:'}, { onCancel: () => process.exit(1) });
+ if (manualId) return manualId;
+ return null;
+ }
+ } else if (action === 'manual') {
+ const { manualId } = await prompts({ type: 'text', name: 'manualId', message: 'Enter the Hyperdrive ID:'}, { onCancel: () => process.exit(1) });
+ if (manualId) return manualId;
+ return null;
+ } else if (action === 'new') {
+ const { newName } = await prompts({ type: 'text', name: 'newName', message: 'Enter a new Hyperdrive name (e.g. codra-db-2):', initial: `${currentBinding}-2`}, { onCancel: () => process.exit(1) });
+ if (newName) {
+ currentBinding = newName;
+ continue;
+ }
+ return null;
+ } else {
+ return null;
+ }
+ } else {
+ spinner.fail();
+ console.error(chalk.red(`\nโ Error executing Hyperdrive creation.`));
+ console.error(chalk.red(errorMsg));
+ process.exit(1);
+ }
+ }
+ }
+}
+
+function getEnvVars() {
+ const env = {};
+ if (fs.existsSync(DEV_VARS_PATH)) {
+ const content = fs.readFileSync(DEV_VARS_PATH, 'utf-8');
+ const lines = content.split(/\r?\n/);
+ for (const line of lines) {
+ if (line.trim() && !line.startsWith('#')) {
+ const [key, ...values] = line.split('=');
+ if (key && values.length > 0) {
+ env[key.trim()] = values.join('=').trim().replace(/^"|"$/g, '');
+ }
+ }
+ }
+ }
+ return env;
+}
+
+function setSecret(secretName, secretValue) {
+ return new Promise((resolve, reject) => {
+ const child = exec(`npx wrangler secret put ${secretName}`, (error, stdout, stderr) => {
+ if (error) reject(new Error(stderr || error.message));
+ else resolve();
+ });
+
+ child.stdin.write(secretValue);
+ child.stdin.end();
+ });
+}
+
+async function main() {
+ console.clear();
+ console.log(chalk.blue.bold('\nโ๏ธ Codra Cloudflare Setup\n'));
+ console.log(chalk.gray('This script will automatically configure your Cloudflare resources.\n'));
+
+ const env = getEnvVars();
+
+ // 1. Prerequisites Check
+ const authSpinner = ora('Checking Cloudflare authentication...').start();
+ let globallyAuthenticated = true;
+ try {
+ const { stdout, stderr } = await execAsync('npx wrangler whoami');
+ const output = (stdout + (stderr || '')).toLowerCase();
+
+ // Wrangler sometimes exits with 0 even when not logged in
+ if (output.includes('not logged in') || output.includes('non-interactive environment') || output.includes('you are not authenticated')) {
+ throw new Error('Not logged in');
+ }
+ authSpinner.succeed('Authenticated with Cloudflare.');
+ } catch (error) {
+ globallyAuthenticated = false;
+ authSpinner.warn('Cloudflare is not authenticated in wrangler.');
+ }
+
+ if (!globallyAuthenticated) {
+ console.error(chalk.red('\nโ You are not logged into Cloudflare.'));
+ console.log(chalk.yellow('Please run `npx wrangler login` in your terminal and try again.'));
+ process.exit(1);
+ }
+
+ // 2. KV Namespace
+ console.log(chalk.cyan.bold('๐ฆ KV Namespaces'));
+ const kvId = await handleKVNamespace('codra-review', false);
+ if (!kvId) console.log(chalk.yellow(' โ ๏ธ Could not extract KV ID.'));
+
+ const kvPreviewId = await handleKVNamespace('codra-review', true);
+ if (!kvPreviewId) console.log(chalk.yellow(' โ ๏ธ Could not extract preview KV ID.'));
+ console.log('');
+
+ // 3. Queues
+ console.log(chalk.cyan.bold('๐จ Queues'));
+ const dlqSpinner = ora('Creating DLQ queue (codra-review-dlq)...').start();
+ try {
+ await execAsync('npx wrangler queues create codra-review-dlq');
+ dlqSpinner.succeed();
+ } catch (e) {
+ if (e.stderr && (e.stderr.includes('already taken') || e.stderr.includes('already exists'))) {
+ dlqSpinner.succeed('DLQ queue (codra-review-dlq) already exists.');
+ } else {
+ dlqSpinner.fail();
+ console.error(chalk.yellow(' โ ๏ธ ' + (e.stderr || e.message)));
+ }
+ }
+
+ const jobsSpinner = ora('Creating jobs queue (codra-review-jobs)...').start();
+ try {
+ await execAsync('npx wrangler queues create codra-review-jobs');
+ jobsSpinner.succeed();
+ } catch (e) {
+ if (e.stderr && (e.stderr.includes('already taken') || e.stderr.includes('already exists'))) {
+ jobsSpinner.succeed('Jobs queue (codra-review-jobs) already exists.');
+ } else {
+ jobsSpinner.fail();
+ console.error(chalk.yellow(' โ ๏ธ ' + (e.stderr || e.message)));
+ }
+ }
+
+ let dlqQueueId = null;
+ const queuesOutputSpinner = ora('Fetching queue information...').start();
+ try {
+ const { stdout } = await execAsync('npx wrangler queues list');
+ queuesOutputSpinner.succeed();
+ const lines = stdout.split('\n');
+ for (const line of lines) {
+ if (line.includes('codra-review-dlq')) {
+ dlqQueueId = extractId(line);
+ }
+ }
+ } catch (e) {
+ queuesOutputSpinner.fail('Failed to fetch queues list.');
+ console.error(chalk.yellow(' โ ๏ธ Could not automatically fetch DLQ queue ID. You may need to manually update CF_DLQ_ID.'));
+ }
+ console.log('');
+
+ // 4. Hyperdrive
+ console.log(chalk.cyan.bold('๐๏ธ Hyperdrive'));
+ console.log(chalk.gray(` (Using default from .dev.vars if available)`));
+ const { dbUrl } = await prompts({
+ type: 'text',
+ name: 'dbUrl',
+ message: 'Enter your Database Connection String for Hyperdrive:',
+ initial: env.DATABASE_URL || 'postgres://user:password@hostname:5432/codra'
+ }, {
+ onCancel: () => {
+ console.log(chalk.red('\n๐ Setup aborted.'));
+ process.exit(1);
+ }
+ });
+
+ if (!dbUrl) {
+ console.log(chalk.red('โ Database URL is required for Hyperdrive. Exiting.'));
+ process.exit(1);
+ }
+
+ const hyperdriveId = await handleHyperdrive(dbUrl);
+ console.log('');
+
+ // 5. Domain Configuration
+ console.log(chalk.cyan.bold('๐ Domain Configuration'));
+ const { domainChoice } = await prompts({
+ type: 'select',
+ name: 'domainChoice',
+ message: 'Where would you like to deploy this application?',
+ choices: [
+ { title: 'Use a workers.dev subdomain (Free & Easy)', value: 'workers_dev' },
+ { title: 'Use a Custom Domain', value: 'custom_domain' }
+ ]
+ }, { onCancel: () => process.exit(1) });
+
+ let appUrl = '';
+ let routesConfigStr = '';
+
+ if (domainChoice === 'workers_dev') {
+ routesConfigStr = `"workers_dev": true`;
+ const { workersDev } = await prompts({
+ type: 'text',
+ name: 'workersDev',
+ message: 'What will be your workers.dev app URL? (e.g. https://codra.username.workers.dev):',
+ initial: 'https://codra..workers.dev'
+ }, { onCancel: () => process.exit(1) });
+ appUrl = workersDev.replace(/\/$/, '');
+ } else {
+ const { customDomain } = await prompts({
+ type: 'text',
+ name: 'customDomain',
+ message: 'Enter your custom domain:',
+ initial: 'app.codra.devarshi.dev'
+ }, { onCancel: () => process.exit(1) });
+
+ appUrl = `https://${customDomain}`;
+ routesConfigStr = `"routes": [
+ {
+ "pattern": "${customDomain}",
+ "custom_domain": true
+ }
+ ]`;
+ }
+ console.log('');
+
+ // 6. Application Variables
+ console.log(chalk.cyan.bold('๐ Application Variables'));
+ const { botUsername } = await prompts({
+ type: 'text',
+ name: 'botUsername',
+ message: 'Enter your GitHub Bot Username:',
+ initial: 'codra-app'
+ }, { onCancel: () => process.exit(1) });
+
+ const { githubAppSlug } = await prompts({
+ type: 'text',
+ name: 'githubAppSlug',
+ message: 'Enter your GitHub App Slug:',
+ initial: 'codra-app-personal'
+ }, { onCancel: () => process.exit(1) });
+
+ const { allowedUsers } = await prompts({
+ type: 'text',
+ name: 'allowedUsers',
+ message: 'Enter comma-separated GitHub usernames allowed to access the dashboard:',
+ initial: 'devarshishimpi'
+ }, { onCancel: () => process.exit(1) });
+ console.log('');
+
+ // 7. Config Update
+ console.log(chalk.cyan.bold('โ๏ธ Configuration'));
+ const configSpinner = ora('Updating wrangler.jsonc...').start();
+ let wranglerConfig = fs.readFileSync(WRANGLER_JSONC_PATH, 'utf-8');
+ let configChanged = false;
+
+ const routeRegex = /"routes"\s*:\s*\[[\s\S]*?\]|"workers_dev"\s*:\s*(true|false)/;
+ wranglerConfig = wranglerConfig.replace(routeRegex, routesConfigStr);
+
+ const appUrlRegex = /"APP_URL":\s*"[^"]+"/;
+ wranglerConfig = wranglerConfig.replace(appUrlRegex, `"APP_URL": "${appUrl}"`);
+
+ const callbackUrlRegex = /"AUTH_CALLBACK_URL":\s*"[^"]+"/;
+ wranglerConfig = wranglerConfig.replace(callbackUrlRegex, `"AUTH_CALLBACK_URL": "${appUrl}/auth/github/callback"`);
+
+ const botUsernameRegex = /"BOT_USERNAME":\s*"[^"]+"/;
+ wranglerConfig = wranglerConfig.replace(botUsernameRegex, `"BOT_USERNAME": "${botUsername}"`);
+
+ const githubAppSlugRegex = /"GITHUB_APP_SLUG":\s*"[^"]+"/;
+ wranglerConfig = wranglerConfig.replace(githubAppSlugRegex, `"GITHUB_APP_SLUG": "${githubAppSlug}"`);
+
+ const allowedUsersRegex = /"DASHBOARD_ALLOWED_USERS":\s*"[^"]+"/;
+ wranglerConfig = wranglerConfig.replace(allowedUsersRegex, `"DASHBOARD_ALLOWED_USERS": "${allowedUsers}"`);
+
+ configChanged = true;
+
+ if (kvId && kvPreviewId) {
+ wranglerConfig = wranglerConfig.replace(
+ /"binding":\s*"APP_KV",\s*"id":\s*"[^"]+",\s*"preview_id":\s*"[^"]+"/,
+ `"binding": "APP_KV",${os.EOL} "id": "${kvId}",${os.EOL} "preview_id": "${kvPreviewId}"`
+ );
+ configChanged = true;
+ }
+
+ if (hyperdriveId) {
+ wranglerConfig = wranglerConfig.replace(
+ /"binding":\s*"HYPERDRIVE",\s*"id":\s*"[^"]+"/,
+ `"binding": "HYPERDRIVE",${os.EOL} "id": "${hyperdriveId}"`
+ );
+ configChanged = true;
+ }
+
+ if (dlqQueueId) {
+ wranglerConfig = wranglerConfig.replace(
+ /"CF_DLQ_ID":\s*"[^"]+"/,
+ `"CF_DLQ_ID": "${dlqQueueId}"`
+ );
+ configChanged = true;
+ }
+
+ if (configChanged) {
+ fs.writeFileSync(WRANGLER_JSONC_PATH, wranglerConfig, 'utf-8');
+ configSpinner.succeed('Updated wrangler.jsonc with new resource IDs.');
+ } else {
+ configSpinner.warn('No IDs were successfully extracted. wrangler.jsonc was not modified.');
+ }
+ console.log('');
+
+ // 8. Secrets
+ console.log(chalk.cyan.bold('๐ Secrets'));
+ const requiredSecrets = [
+ "APP_PRIVATE_KEY",
+ "GITHUB_APP_ID",
+ "GITHUB_APP_WEBHOOK_SECRET",
+ "GITHUB_CLIENT_ID",
+ "GITHUB_CLIENT_SECRET",
+ "LLM_CONFIG_ENCRYPTION_KEY",
+ "CF_API_TOKEN",
+ "CF_ACCOUNT_ID"
+ ];
+
+ const { confirmSecrets } = await prompts({
+ type: 'confirm',
+ name: 'confirmSecrets',
+ message: 'Would you like to interactively configure the required Cloudflare secrets now?',
+ initial: true
+ }, {
+ onCancel: () => {
+ console.log(chalk.red('\n๐ Setup aborted.'));
+ process.exit(1);
+ }
+ });
+
+ if (confirmSecrets) {
+ console.log('');
+ for (const secretName of requiredSecrets) {
+ let initialVal = env[secretName] || '';
+
+ const { secretValue } = await prompts({
+ type: 'text',
+ name: 'secretValue',
+ message: `Value for ${secretName}:`,
+ initial: initialVal || undefined,
+ style: secretName === 'APP_PRIVATE_KEY' ? 'default' : 'password'
+ }, {
+ onCancel: () => {
+ console.log(chalk.red('\n๐ Setup aborted.'));
+ process.exit(1);
+ }
+ });
+
+ if (secretValue) {
+ const spinner = ora(`Setting secret ${secretName}...`).start();
+ try {
+ await setSecret(secretName, secretValue);
+ spinner.succeed();
+ } catch (e) {
+ spinner.fail();
+ console.error(chalk.red(` โ Failed to set secret ${secretName}: ${e.message}`));
+ }
+ } else {
+ console.log(chalk.yellow(` โญ๏ธ Skipped ${secretName}`));
+ }
+ }
+ }
+
+ console.log(chalk.green.bold('\n============================================='));
+ console.log(chalk.green.bold('๐ Cloudflare Setup Successfully Completed!'));
+ console.log(chalk.green.bold('=============================================\n'));
+ console.log(chalk.white('You are all set. Run ') + chalk.cyan('npm run deploy') + chalk.white(' to deploy Codra to Cloudflare.\n'));
+}
+
+main().catch(error => {
+ console.error(chalk.red('\nโ An unexpected error occurred:'));
+ console.error(error);
+ process.exit(1);
+});
From 6d361b58224fd8fec02258e3e29cf80aa6823d7f Mon Sep 17 00:00:00 2001
From: Devarshi Shimpi
Date: Sun, 7 Jun 2026 13:14:53 +0530
Subject: [PATCH 2/7] add: redesign UI with updated design system and version
display
---
README.md | 2 +
src/client/app.css | 51 +++++++++++---------
src/client/components/layout/page-header.tsx | 8 ++-
src/client/components/shared/jobs-table.tsx | 16 +++---
src/client/components/ui/button.tsx | 2 +-
src/client/components/ui/dropdown-menu.tsx | 4 +-
src/client/components/ui/input.tsx | 2 +-
src/client/components/ui/select.tsx | 15 ++++--
src/client/pages/settings.tsx | 50 ++++++++++++++++++-
9 files changed, 109 insertions(+), 41 deletions(-)
diff --git a/README.md b/README.md
index 411c3bb..19bb877 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,8 @@
Codra listens to GitHub pull request events, runs AI-powered review jobs, posts inline findings back to the PR, and gives you a dashboard to inspect jobs, repositories, model routing, review history, and failed queue runs.
+> **Beta** -- Codra is under active development. Expect rough edges, missing features, and breaking changes between releases. Feedback and bug reports are welcome via [GitHub Issues](https://github.com/devarshishimpi/codra/issues).
+
## Why Codra
- **Own the whole review loop**: Run the GitHub App, Cloudflare Worker, queue, database, model credentials, and dashboard under your own control.
diff --git a/src/client/app.css b/src/client/app.css
index a09fe98..b1677d2 100644
--- a/src/client/app.css
+++ b/src/client/app.css
@@ -16,7 +16,7 @@
:root {
/* Surfaces - Pure & Crisp */
/* Surfaces - High-Contrast */
- --background: #ffffff;
+ --background: #f4f4f5;
--foreground: oklch(12% 0.02 115);
--card: #ffffff;
--card-foreground: oklch(12% 0.02 115);
@@ -27,27 +27,27 @@
--primary: oklch(64% 0.24 115);
--primary-foreground: oklch(100% 0 0); /* White text on the deeper green */
- /* Secondary / muted - Subtly cool */
- --secondary: oklch(96% 0.006 115);
- --secondary-foreground:oklch(25% 0.020 115);
- --muted: oklch(96% 0.006 115);
- --muted-foreground: oklch(44% 0.015 115);
+ /* Secondary / muted - Zinc */
+ --secondary: #f4f4f5;
+ --secondary-foreground:#27272a;
+ --muted: #f4f4f5;
+ --muted-foreground: #71717a;
- /* Accent */
- --accent: oklch(94% 0.03 115);
- --accent-foreground: oklch(18% 0.016 115);
+ /* Accent - slightly darker zinc for visible hover on white popovers */
+ --accent: #e4e4e7;
+ --accent-foreground: #18181b;
/* Destructive */
--destructive: oklch(55% 0.22 25);
--destructive-foreground: oklch(100% 0 0);
/* Border / input / ring */
- --border: oklch(88% 0.008 115);
- --input: oklch(88% 0.008 115);
+ --border: #e4e4e7;
+ --input: #e4e4e7;
--ring: oklch(72% 0.22 115);
/* Radius */
- --radius: 0.5rem;
+ --radius: 0.75rem;
--sidebar-width: 240px;
--sidebar-collapsed-width: 72px;
@@ -66,9 +66,9 @@
--info-border: oklch(88% 0.12 250);
/* Premium Shadows */
- --shadow-sm: 0 1px 2px oklch(0% 0 0 / 0.05);
- --shadow-md: 0 4px 12px oklch(0% 0 0 / 0.06), 0 1px 4px oklch(0% 0 0 / 0.03);
- --shadow-lg: 0 12px 24px -4px oklch(0% 0 0 / 0.08), 0 4px 12px -2px oklch(0% 0 0 / 0.04);
+ --shadow-sm: 0 1px 2px oklch(0% 0 0 / 0.02);
+ --shadow-md: 0 1px 4px oklch(0% 0 0 / 0.03), 0 1px 2px oklch(0% 0 0 / 0.02);
+ --shadow-lg: 0 4px 16px -4px oklch(0% 0 0 / 0.04), 0 1px 6px -2px oklch(0% 0 0 / 0.03);
/* Code Blocks (Zinc) */
--code-bg: #f4f4f5;
@@ -173,10 +173,10 @@
--color-info-bg: var(--info-bg);
--color-info-border: var(--info-border);
- --radius-sm: 2px;
- --radius-md: 4px;
- --radius-lg: 8px;
- --radius-xl: 16px;
+ --radius-sm: 4px;
+ --radius-md: 8px;
+ --radius-lg: 12px;
+ --radius-xl: 18px;
--radius-2xl: 32px;
/* Fluid Typography Scale (Ratio: 1.25) */
@@ -495,9 +495,8 @@
Surface & Utilities
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
@utility surface {
- @apply bg-card border border-border rounded-md;
+ @apply bg-card border border-border rounded-xl;
box-shadow: var(--shadow-md);
- &:hover { box-shadow: var(--shadow-md); }
}
.surface-static {
@@ -657,11 +656,13 @@
}
.app-shell-content {
- --background: #ffffff;
+ --background: #f4f4f5;
--card: #ffffff;
- --muted: #ffffff;
+ --muted: #f4f4f5;
--popover: #ffffff;
- --secondary: #ffffff;
+ --secondary: #f4f4f5;
+ --border: #e4e4e7;
+ --input: #e4e4e7;
}
.dark .app-shell-content {
@@ -670,6 +671,8 @@
--muted: #09090b;
--popover: #09090b;
--secondary: #09090b;
+ --border: oklch(22% 0.02 115);
+ --input: oklch(22% 0.02 115);
}
.dashboard-sidebar-divider {
diff --git a/src/client/components/layout/page-header.tsx b/src/client/components/layout/page-header.tsx
index 026c765..3590e19 100644
--- a/src/client/components/layout/page-header.tsx
+++ b/src/client/components/layout/page-header.tsx
@@ -7,6 +7,7 @@ interface PageHeaderProps extends React.HTMLAttributes {
title: string;
description?: React.ReactNode;
actions?: React.ReactNode;
+ versionBadge?: string;
}
export function PageHeader({
@@ -28,10 +29,15 @@ export function PageHeader({
{category}
{title}
+ {props.versionBadge && (
+
+ v{props.versionBadge}
+
+ )}
{description && (
diff --git a/src/client/components/shared/jobs-table.tsx b/src/client/components/shared/jobs-table.tsx
index 2bd99be..f135ccc 100644
--- a/src/client/components/shared/jobs-table.tsx
+++ b/src/client/components/shared/jobs-table.tsx
@@ -40,8 +40,8 @@ const thCls =
'px-4 py-3 text-left text-[10px] font-bold uppercase tracking-[0.16em] text-muted-foreground select-none';
const COLUMN_CLASSES: Record
= {
- repo: 'w-[190px]',
- pr: 'min-w-[280px]',
+ repo: 'w-[190px] max-w-[190px]',
+ pr: 'max-w-[480px]',
status: 'w-[150px]',
verdict: 'w-[120px]',
files: 'hidden md:table-cell w-[76px]',
@@ -262,7 +262,7 @@ export function JobsTable({ jobs, loading, columns }: JobsTableProps) {
return (
{cols.includes('repo') && (
-
+
-
-
+
+
#{job.prNumber}
{job.prTitle ?? 'Untitled PR'}
diff --git a/src/client/components/ui/button.tsx b/src/client/components/ui/button.tsx
index 9d79858..2ae9a8d 100644
--- a/src/client/components/ui/button.tsx
+++ b/src/client/components/ui/button.tsx
@@ -13,7 +13,7 @@ const buttonVariants = cva(
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline:
- 'border border-input bg-card/60 shadow-sm hover:bg-secondary hover:text-secondary-foreground',
+ 'border border-zinc-200 bg-white shadow-sm hover:bg-zinc-50 hover:text-zinc-900 dark:border-input dark:bg-card/60 dark:hover:bg-secondary dark:hover:text-secondary-foreground',
secondary:
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-secondary hover:text-secondary-foreground',
diff --git a/src/client/components/ui/dropdown-menu.tsx b/src/client/components/ui/dropdown-menu.tsx
index df58e9a..32c61fc 100644
--- a/src/client/components/ui/dropdown-menu.tsx
+++ b/src/client/components/ui/dropdown-menu.tsx
@@ -16,10 +16,10 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const menuContentClass =
- 'z-50 min-w-[8rem] overflow-hidden rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-xl shadow-black/10 dark:shadow-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 duration-200';
+ 'z-50 min-w-[8rem] overflow-hidden rounded-lg border border-zinc-200 bg-white p-1 text-zinc-900 shadow-lg shadow-black/[0.06] dark:border-border dark:bg-popover dark:text-popover-foreground dark:shadow-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 duration-200';
const menuItemClass =
- 'relative flex cursor-default select-none items-center rounded-md text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground dark:hover:bg-primary/[0.12] dark:focus:bg-primary/[0.12] dark:data-[highlighted]:bg-primary/[0.12] data-[disabled]:pointer-events-none data-[disabled]:opacity-50';
+ 'relative flex cursor-default select-none items-center rounded-md text-sm outline-none transition-colors hover:bg-zinc-200 hover:text-zinc-900 focus:bg-zinc-200 focus:text-zinc-900 data-[highlighted]:bg-zinc-200 data-[highlighted]:text-zinc-900 dark:hover:bg-primary/[0.12] dark:hover:text-foreground dark:focus:bg-primary/[0.12] dark:focus:text-foreground dark:data-[highlighted]:bg-primary/[0.12] dark:data-[highlighted]:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50';
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef,
diff --git a/src/client/components/ui/input.tsx b/src/client/components/ui/input.tsx
index 345dcbc..1970d23 100644
--- a/src/client/components/ui/input.tsx
+++ b/src/client/components/ui/input.tsx
@@ -8,7 +8,7 @@ const Input = React.forwardRef(({ className, type,
@@ -77,7 +85,8 @@ export function Select({
onClick={() => onValueChange(option.value)}
className={cn(
'cursor-pointer whitespace-normal break-words py-2',
- value === option.value && 'bg-primary/10 font-medium text-primary dark:bg-primary/[0.12]'
+ value === option.value &&
+ 'bg-primary/10 font-medium text-primary dark:bg-primary/[0.12] dark:text-primary',
)}
>
{option.label}
diff --git a/src/client/pages/settings.tsx b/src/client/pages/settings.tsx
index cde3db8..371b1ca 100644
--- a/src/client/pages/settings.tsx
+++ b/src/client/pages/settings.tsx
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
+import pkg from '../../../package.json';
import { toast } from 'sonner';
import { api, type ProviderPayload } from '@client/lib/api';
import { PageHeader } from '@client/components/layout/page-header';
@@ -21,6 +22,9 @@ import {
ChevronDown,
ChevronRight,
X,
+ Tag,
+ ExternalLink,
+ GitCommit,
} from 'lucide-react';
import type { LlmApiFormat, LlmProvider, ModelConfig, RepoConfig } from '@shared/schema';
import type { ModelConfigsResponse } from '@shared/api';
@@ -1142,7 +1146,7 @@ export function SettingsPage() {
))}
) : (
-
+
{filteredConfigs.map((cfg) => {
const saved = savedConfigs.find(item => item.modelId === cfg.modelId);
const dirty = !configEqual(cfg, saved);
@@ -1280,6 +1284,50 @@ export function SettingsPage() {
)}
+
+ {/* โโ System Information โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */}
+
+
+
+ {/* Version row */}
+
+
+
+
+
+
Version
+
Installed Codra release
+
+
+ v{pkg.version}
+
+
+
+ {/* Changelog / links row */}
+
+
+
+
);
}
From b5361fd07f5e3deb971e1350962f69d2f5c96d1d Mon Sep 17 00:00:00 2001
From: Devarshi Shimpi
Date: Sun, 7 Jun 2026 21:21:08 +0530
Subject: [PATCH 3/7] refactor: lazy load pages + redesign auth pages
- Version 0.9.2
- Implement code splitting with React.lazy for all pages
- Add Suspense fallbacks for async route components
- Redesign landing page (hero, sign-in, feature layout)
- Redesign login page (cleaner UI, security note)
- Minor app-shell spacing adjustment
---
package.json | 2 +-
src/client/components/layout/app-shell.tsx | 1 +
src/client/main.tsx | 50 +++++----
src/client/pages/landing.tsx | 120 ++++++++++++---------
src/client/pages/login.tsx | 75 ++++++-------
5 files changed, 137 insertions(+), 111 deletions(-)
diff --git a/package.json b/package.json
index 8cb7355..0210a1f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "codra",
- "version": "0.9.0",
+ "version": "0.9.2",
"description": "Open-source code review engine",
"author": "Devarshi Shimpi",
"license": "AGPL-3.0-only",
diff --git a/src/client/components/layout/app-shell.tsx b/src/client/components/layout/app-shell.tsx
index 18f99a8..f467b26 100644
--- a/src/client/components/layout/app-shell.tsx
+++ b/src/client/components/layout/app-shell.tsx
@@ -146,6 +146,7 @@ export function AppShell() {
className={cn(
'flex min-w-0 items-center gap-2.5 rounded-lg p-1 -m-1',
'transition-opacity duration-150 hover:opacity-75',
+ !sidebarCollapsed && 'lg:ml-1.5',
sidebarCollapsed && 'lg:justify-center',
)}
aria-label="Codra dashboard"
diff --git a/src/client/main.tsx b/src/client/main.tsx
index 95b7dbf..7e40f75 100644
--- a/src/client/main.tsx
+++ b/src/client/main.tsx
@@ -1,18 +1,20 @@
-import React from 'react';
+import React, { Suspense } from 'react';
import ReactDOM from 'react-dom/client';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { Toaster } from 'sonner';
import { AppShell } from './components/layout/app-shell';
-import { LandingPage } from './pages/landing';
-import { DashboardPage } from './pages/dashboard';
-import { LoginPage } from './pages/login';
-import { JobsPage } from './pages/jobs';
-import { JobDetailPage } from './pages/job-detail';
-import { JobLogsPage } from './pages/job-logs';
-import { ReposPage } from './pages/repos';
-import { StatsPage } from './pages/stats';
-import { SettingsPage } from './pages/settings';
-import { NotFoundPage } from './pages/not-found';
+
+const LandingPage = React.lazy(() => import('./pages/landing').then(m => ({ default: m.LandingPage })));
+const DashboardPage = React.lazy(() => import('./pages/dashboard').then(m => ({ default: m.DashboardPage })));
+const LoginPage = React.lazy(() => import('./pages/login').then(m => ({ default: m.LoginPage })));
+const JobsPage = React.lazy(() => import('./pages/jobs').then(m => ({ default: m.JobsPage })));
+const JobDetailPage = React.lazy(() => import('./pages/job-detail').then(m => ({ default: m.JobDetailPage })));
+const JobLogsPage = React.lazy(() => import('./pages/job-logs').then(m => ({ default: m.JobLogsPage })));
+const ReposPage = React.lazy(() => import('./pages/repos').then(m => ({ default: m.ReposPage })));
+const StatsPage = React.lazy(() => import('./pages/stats').then(m => ({ default: m.StatsPage })));
+const SettingsPage = React.lazy(() => import('./pages/settings').then(m => ({ default: m.SettingsPage })));
+const NotFoundPage = React.lazy(() => import('./pages/not-found').then(m => ({ default: m.NotFoundPage })));
+
import './app.css';
import { ThemeProvider } from './lib/theme';
@@ -49,30 +51,36 @@ function ToasterWrapper() {
);
}
+const withSuspense = (Component: React.ComponentType, isFullPage = false) => (
+ }>
+
+
+);
+
const router = createBrowserRouter([
{
path: '/',
- element: ,
+ element: withSuspense(LandingPage, true),
},
{
path: '/login',
- element: ,
+ element: withSuspense(LoginPage, true),
},
{
element: ,
children: [
- { path: 'dashboard', element: },
- { path: 'jobs', element: },
- { path: 'jobs/:id', element: },
- { path: 'jobs/:id/logs', element: },
- { path: 'repos', element: },
- { path: 'stats', element: },
- { path: 'settings', element: },
+ { path: 'dashboard', element: withSuspense(DashboardPage) },
+ { path: 'jobs', element: withSuspense(JobsPage) },
+ { path: 'jobs/:id', element: withSuspense(JobDetailPage) },
+ { path: 'jobs/:id/logs', element: withSuspense(JobLogsPage) },
+ { path: 'repos', element: withSuspense(ReposPage) },
+ { path: 'stats', element: withSuspense(StatsPage) },
+ { path: 'settings', element: withSuspense(SettingsPage) },
],
},
{
path: '*',
- element: ,
+ element: withSuspense(NotFoundPage, true),
},
]);
diff --git a/src/client/pages/landing.tsx b/src/client/pages/landing.tsx
index 86d4233..cb5fb86 100644
--- a/src/client/pages/landing.tsx
+++ b/src/client/pages/landing.tsx
@@ -3,63 +3,91 @@ import { useTheme } from '@client/lib/theme';
import codraDark from '@/assets/codra-fullicon-dark.svg';
import codraLight from '@/assets/codra-fullicon-light.svg';
+const FEATURES = [
+ {
+ title: 'Understands your codebase',
+ desc: 'Reviews diffs with full context from the surrounding code, not just the changed lines.',
+ },
+ {
+ title: 'Flags real issues',
+ desc: 'Security vulnerabilities, logic errors, and pattern violations โ surfaced before merge.',
+ },
+ {
+ title: 'Configurable per repo',
+ desc: 'Set review depth, model chain, and strictness from the dashboard. No config files.',
+ },
+];
+
export function LandingPage() {
const { theme, toggleTheme } = useTheme();
return (
- {/* Header */}
-
+ {/* โโ Header โโ */}
+
-
- {theme === 'dark' ? : }
-
+
- {/* Body */}
-
+ {/* โโ Body โโ */}
+
- {/* Left โ Identity & CTA */}
-
+ {/* Left โ Hero */}
+
-
-
-
- AI code review
- on every PR.
+
+ {/* Badge */}
+
+ AI-powered ยท GitHub App
+
+
+
+
+ AI code review on every PR.
-
- Codra reviews pull requests automatically โ checking for bugs, security issues,
- and code patterns specific to your repository.
+
+ Codra reviews pull requests automatically โ checking for bugs,
+ security issues, and code patterns specific to your repository.
-
- Sign in with GitHub
-
+
+ Get started with GitHub
+
{/* Footer links */}
-
- {/* Right โ What it does */}
-
-
- {[
- {
- title: 'Understands your codebase',
- desc: 'Reviews diffs with context from the surrounding code, not just the changed lines.',
- },
- {
- title: 'Flags real issues',
- desc: 'Security vulnerabilities, logic errors, and pattern violations โ surfaced before merge.',
- },
- {
- title: 'Configurable per repo',
- desc: 'Set review depth, model chain, and strictness from the dashboard. No config files.',
- },
- ].map((item) => (
-
-
{item.title}
-
{item.desc}
+ {/* Right โ Features */}
+
+
+ What it does
+
+
+
+ {FEATURES.map((item, i) => (
+
+
+ {i + 1}
+
+
+
{item.title}
+
{item.desc}
+
))}
diff --git a/src/client/pages/login.tsx b/src/client/pages/login.tsx
index 0300777..e94937c 100644
--- a/src/client/pages/login.tsx
+++ b/src/client/pages/login.tsx
@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Button } from '@client/components/ui/button';
-import { Sun, Moon } from 'lucide-react';
+import { Sun, Moon, ShieldCheck } from 'lucide-react';
import { useTheme } from '@client/lib/theme';
import codraDark from '@/assets/codra-fullicon-dark.svg';
import codraLight from '@/assets/codra-fullicon-light.svg';
@@ -29,7 +29,7 @@ export function LoginPage() {
const error = useMemo(() => getErrorMessage(searchParams.get('error')), [searchParams]);
return (
-
+
:
}
-
-
-
-
-
-
-
-
-
- PR review control panel. Sign in with the approved GitHub account for this Codra instance.
-
-
+ {/* Card */}
+
+
-
-
- Dashboard access is restricted to GitHub users listed in the deployment allowlist. This production instance currently accepts the configured owner account only.
+ {/* Logo */}
+
+
+ {/* Heading + sub */}
+
+
+ Welcome back
+
+
+ Sign in with your approved GitHub account to access the PR review dashboard.
+
+ {/* Error */}
{error && (
-
+
+ {/* Footer note โ outside the card */}
+
+
+
+ Only authorized GitHub users can access this instance.
+
+
);
From fa71068dbffc24a72eccb3517b771e8987de3409 Mon Sep 17 00:00:00 2001
From: Devarshi Shimpi
Date: Sun, 7 Jun 2026 21:55:37 +0530
Subject: [PATCH 4/7] refactor: extract page header actions and improve dark
mode theming
---
scripts/migrate.mjs | 25 +++++++----
.../features/job-detail/comment-card.tsx | 2 +-
.../features/stats/time-range-select.tsx | 5 ++-
src/client/components/shared/jobs-table.tsx | 8 +++-
.../components/shared/page-header-actions.tsx | 42 +++++++++++++++++++
src/client/components/ui/select.tsx | 5 ++-
src/client/pages/dashboard.tsx | 27 ++++--------
src/client/pages/stats.tsx | 20 ++++-----
8 files changed, 88 insertions(+), 46 deletions(-)
create mode 100644 src/client/components/shared/page-header-actions.tsx
diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs
index 80b22bf..bd11b9f 100644
--- a/scripts/migrate.mjs
+++ b/scripts/migrate.mjs
@@ -401,8 +401,12 @@ async function normalizeRepoConfigs() {
return;
}
+ console.log('Normalizing repo configs...');
+ const functionName = 'codra_replace_deprecated_model_' + Date.now();
+
+ console.log(`Creating function: ${functionName}`);
await query(`
- CREATE OR REPLACE FUNCTION pg_temp.codra_replace_deprecated_model(input jsonb, old_value text, new_value text)
+ CREATE FUNCTION public.${functionName}(input jsonb, old_value text, new_value text)
RETURNS jsonb
LANGUAGE sql
IMMUTABLE
@@ -411,14 +415,14 @@ async function normalizeRepoConfigs() {
WHEN 'string' THEN CASE WHEN input #>> '{}' = old_value THEN to_jsonb(new_value) ELSE input END
WHEN 'array' THEN COALESCE(
(
- SELECT jsonb_agg(pg_temp.codra_replace_deprecated_model(value, old_value, new_value) ORDER BY ord)
+ SELECT jsonb_agg(public.${functionName}(value, old_value, new_value) ORDER BY ord)
FROM jsonb_array_elements(input) WITH ORDINALITY AS item(value, ord)
),
'[]'::jsonb
)
WHEN 'object' THEN COALESCE(
(
- SELECT jsonb_object_agg(key, pg_temp.codra_replace_deprecated_model(value, old_value, new_value))
+ SELECT jsonb_object_agg(key, public.${functionName}(value, old_value, new_value))
FROM jsonb_each(input)
),
'{}'::jsonb
@@ -428,6 +432,7 @@ async function normalizeRepoConfigs() {
$$
`);
+ console.log('Updating repo configs...');
await query(
`
UPDATE repo_configs
@@ -435,15 +440,15 @@ async function normalizeRepoConfigs() {
main_model = CASE WHEN main_model = $1 THEN $2 ELSE main_model END,
fallback_models = CASE
WHEN fallback_models IS NULL THEN NULL
- ELSE pg_temp.codra_replace_deprecated_model(fallback_models, $1, $2)
+ ELSE public.${functionName}(fallback_models, $1, $2)
END,
size_overrides = CASE
WHEN size_overrides IS NULL THEN NULL
- ELSE pg_temp.codra_replace_deprecated_model(size_overrides, $1, $2)
+ ELSE public.${functionName}(size_overrides, $1, $2)
END,
parsed_json = CASE
WHEN parsed_json IS NULL THEN NULL
- ELSE pg_temp.codra_replace_deprecated_model(parsed_json, $1, $2)
+ ELSE public.${functionName}(parsed_json, $1, $2)
END
WHERE main_model = $1
OR fallback_models::text LIKE '%' || $1 || '%'
@@ -453,12 +458,14 @@ async function normalizeRepoConfigs() {
[kimiK25Model, kimiK26Model],
);
- await query('DROP FUNCTION IF EXISTS pg_temp.codra_replace_deprecated_model(jsonb, text, text)');
+ console.log(`Dropping function: ${functionName}`);
+ await query(`DROP FUNCTION IF EXISTS public.${functionName}(jsonb, text, text)`);
+ console.log('Repo configs normalized.');
}
async function main() {
- await query('SELECT pg_advisory_lock($1)', [migrationLockId]);
try {
+ console.log('Starting database migrations...');
await ensureMigrationTable();
const migrationFiles = (await readdir(migrationsDir))
@@ -472,13 +479,13 @@ async function main() {
}
}
+ console.log('Running catalog and config normalizations...');
await query('DROP INDEX IF EXISTS repositories_owner_idx');
await ensureModelCatalog();
await normalizeRepoConfigs();
console.log('Database migrations are up to date.');
} finally {
- await query('SELECT pg_advisory_unlock($1)', [migrationLockId]);
await sql.end();
}
}
diff --git a/src/client/components/features/job-detail/comment-card.tsx b/src/client/components/features/job-detail/comment-card.tsx
index 0e66c38..6edd77c 100644
--- a/src/client/components/features/job-detail/comment-card.tsx
+++ b/src/client/components/features/job-detail/comment-card.tsx
@@ -21,7 +21,7 @@ export function CommentCard({ comment, filePath }: CommentCardProps) {
return (
diff --git a/src/client/components/features/stats/time-range-select.tsx b/src/client/components/features/stats/time-range-select.tsx
index f82c81f..4bd7a50 100644
--- a/src/client/components/features/stats/time-range-select.tsx
+++ b/src/client/components/features/stats/time-range-select.tsx
@@ -1,3 +1,4 @@
+import type { CSSProperties } from 'react';
import { Clock } from 'lucide-react';
import { Select } from '@client/components/ui/select';
import { cn } from '@client/lib/utils';
@@ -6,6 +7,7 @@ interface TimeRangeSelectProps {
value: number;
onValueChange: (value: number) => void;
className?: string;
+ triggerStyle?: CSSProperties;
}
const timeRanges = [
@@ -15,7 +17,7 @@ const timeRanges = [
{ label: 'Last 90 days', value: 90 },
];
-export function TimeRangeSelect({ value, onValueChange, className }: TimeRangeSelectProps) {
+export function TimeRangeSelect({ value, onValueChange, className, triggerStyle }: TimeRangeSelectProps) {
const selectedRange = timeRanges.find((r) => r.value === value) || timeRanges[2];
return (
@@ -28,6 +30,7 @@ export function TimeRangeSelect({ value, onValueChange, className }: TimeRangeSe
}))}
leadingIcon={ }
triggerClassName={cn('w-44', className)}
+ triggerStyle={triggerStyle}
/>
);
}
diff --git a/src/client/components/shared/jobs-table.tsx b/src/client/components/shared/jobs-table.tsx
index f135ccc..59723bc 100644
--- a/src/client/components/shared/jobs-table.tsx
+++ b/src/client/components/shared/jobs-table.tsx
@@ -8,6 +8,7 @@ import {
import { StatusBadge } from '@client/components/ui/badge';
import { Skeleton } from '@client/components/shared/skeleton';
import { cn, fmtNumber } from '@client/lib/utils';
+import { useIsDarkMode } from '@client/hooks/use-is-dark-mode';
import type { JobSummary } from '@shared/schema';
type Column =
@@ -198,6 +199,8 @@ function JobMobileCard({ job, columns }: { job: JobSummary; columns: Column[] })
export function JobsTable({ jobs, loading, columns }: JobsTableProps) {
const cols: Column[] = columns ?? DEFAULT_COLUMNS;
const tableMinWidth = cols.length > 7 ? 'min-w-[980px]' : 'min-w-[720px]';
+ const isDark = useIsDarkMode();
+ const itemBg = isDark ? '#09090b' : '#fafafa';
return (
@@ -272,7 +275,7 @@ export function JobsTable({ jobs, loading, columns }: JobsTableProps) {
)}
>
-
+
{job.repo.slice(0, 2).toUpperCase()}
@@ -407,7 +410,8 @@ export function JobsTable({ jobs, loading, columns }: JobsTableProps) {
>
diff --git a/src/client/components/shared/page-header-actions.tsx b/src/client/components/shared/page-header-actions.tsx
new file mode 100644
index 0000000..4fe9505
--- /dev/null
+++ b/src/client/components/shared/page-header-actions.tsx
@@ -0,0 +1,42 @@
+import { RefreshCw } from 'lucide-react';
+import { Button } from '@client/components/ui/button';
+import { TimeRangeSelect } from '@client/components/features/stats/time-range-select';
+import { useIsDarkMode } from '@client/hooks/use-is-dark-mode';
+
+interface PageHeaderActionsProps {
+ days: number;
+ onDaysChange: (days: number) => void;
+ onRefresh: () => void;
+ refreshing: boolean;
+}
+
+export function PageHeaderActions({
+ days,
+ onDaysChange,
+ onRefresh,
+ refreshing,
+}: PageHeaderActionsProps) {
+ const isDark = useIsDarkMode();
+ const btnBg = isDark ? undefined : '#ffffff';
+
+ return (
+ <>
+
+
+
+ Refresh
+
+ >
+ );
+}
diff --git a/src/client/components/ui/select.tsx b/src/client/components/ui/select.tsx
index efb9830..1a409c7 100644
--- a/src/client/components/ui/select.tsx
+++ b/src/client/components/ui/select.tsx
@@ -1,5 +1,5 @@
import { ChevronDown } from 'lucide-react';
-import type { ReactNode } from 'react';
+import type { CSSProperties, ReactNode } from 'react';
import { cn } from '@client/lib/utils';
import {
DropdownMenu,
@@ -22,6 +22,7 @@ interface SelectProps {
label?: string;
className?: string;
triggerClassName?: string;
+ triggerStyle?: CSSProperties;
leadingIcon?: ReactNode;
/**
* 'page' โ trigger sits on the gray page background (e.g. "Last 30 days").
@@ -41,6 +42,7 @@ export function Select({
label,
className,
triggerClassName,
+ triggerStyle,
leadingIcon,
}: SelectProps) {
const selectedOption = options.find((opt) => opt.value === value);
@@ -61,6 +63,7 @@ export function Select({
!selectedOption && 'text-muted-foreground',
triggerClassName,
)}
+ style={triggerStyle}
>
{leadingIcon && (
diff --git a/src/client/pages/dashboard.tsx b/src/client/pages/dashboard.tsx
index 6c45a55..67f05a4 100644
--- a/src/client/pages/dashboard.tsx
+++ b/src/client/pages/dashboard.tsx
@@ -2,12 +2,12 @@ import { useState } from 'react';
import { api } from '@client/lib/api';
import type { StatsPayload } from '@shared/schema';
import type { JobSummary } from '@shared/schema';
-import { RefreshCw, ArrowRight } from 'lucide-react';
+import { ArrowRight } from 'lucide-react';
import { JobsTable } from '@client/components/shared/jobs-table';
+import { PageHeaderActions } from '@client/components/shared/page-header-actions';
import { Link } from 'react-router-dom';
import { Button } from '@client/components/ui/button';
-import { TimeRangeSelect } from '@client/components/features/stats/time-range-select';
import { PageHeader } from '@client/components/layout/page-header';
import { OverviewStats } from '@client/components/features/stats/overview-stats';
import { usePolling } from '@client/hooks/use-polling';
@@ -51,23 +51,12 @@ export function DashboardPage() {
title="Dashboard"
description="Totals and recent review jobs for the selected time range."
actions={
- <>
-
-
- load(true)}
- disabled={refreshing}
- className="gap-2"
- >
-
- Refresh
-
- >
+ load(true)}
+ refreshing={refreshing}
+ />
}
/>
diff --git a/src/client/pages/stats.tsx b/src/client/pages/stats.tsx
index e8eb83a..ae77c7d 100644
--- a/src/client/pages/stats.tsx
+++ b/src/client/pages/stats.tsx
@@ -24,6 +24,7 @@ import {
} from 'lucide-react';
import { TimeRangeSelect } from '@client/components/features/stats/time-range-select';
+import { PageHeaderActions } from '@client/components/shared/page-header-actions';
import { PageHeader } from '@client/components/layout/page-header';
import { Skeleton } from '@client/components/shared/skeleton';
import { Alert } from '@client/components/ui/alert';
@@ -471,19 +472,12 @@ export function StatsPage() {
title="Review metrics"
description="Daily review and comment activity for the selected range."
actions={
- <>
-
- load(true)}
- disabled={refreshing}
- className="gap-2"
- >
-
- Refresh
-
- >
+ load(true)}
+ refreshing={refreshing}
+ />
}
/>
From 5379dfc8a951fd363bab900192d58c1a53190220 Mon Sep 17 00:00:00 2001
From: Devarshi Shimpi
Date: Sun, 7 Jun 2026 22:09:25 +0530
Subject: [PATCH 5/7] fix: handle literal \n in APP_PRIVATE_KEY and deduplicate
updates-email fetches
- pemToArrayBuffer: strip literal \\n escape sequences before atob()
to handle keys pasted as single-line strings in wrangler secrets
- api.ts: cache the updates-email status promise so the /api/auth/updates-email
endpoint is only called once per page session, reducing KV reads
---
src/client/lib/api.ts | 16 ++++++++++++++--
src/client/pages/landing.tsx | 7 +------
src/server/core/github.ts | 3 +++
3 files changed, 18 insertions(+), 8 deletions(-)
diff --git a/src/client/lib/api.ts b/src/client/lib/api.ts
index f48d43b..b2ff1e8 100644
--- a/src/client/lib/api.ts
+++ b/src/client/lib/api.ts
@@ -118,6 +118,8 @@ async function requestWithMeta(input: string, init?: RequestInit) {
};
}
+let updatesEmailPromise: Promise | null = null;
+
export const api = {
getSession() {
return request('/api/auth/session');
@@ -128,13 +130,23 @@ export const api = {
});
},
getUpdatesEmailStatus() {
- return request('/api/auth/updates-email');
+ if (!updatesEmailPromise) {
+ updatesEmailPromise = request('/api/auth/updates-email').catch((err) => {
+ updatesEmailPromise = null;
+ throw err;
+ });
+ }
+ return updatesEmailPromise;
},
subscribeUpdates(email: string) {
- return request('/api/auth/updates-email', {
+ updatesEmailPromise = request('/api/auth/updates-email', {
method: 'POST',
body: JSON.stringify({ email }),
+ }).catch((err) => {
+ updatesEmailPromise = null;
+ throw err;
});
+ return updatesEmailPromise;
},
getJobs(params: Record = {}) {
const searchParams = new URLSearchParams();
diff --git a/src/client/pages/landing.tsx b/src/client/pages/landing.tsx
index cb5fb86..4818b57 100644
--- a/src/client/pages/landing.tsx
+++ b/src/client/pages/landing.tsx
@@ -56,11 +56,6 @@ export function LandingPage() {
- {/* Badge */}
-
- AI-powered ยท GitHub App
-
-
AI code review on every PR.
@@ -116,7 +111,7 @@ export function LandingPage() {
{item.title}
-
{item.desc}
+
{item.desc}
))}
diff --git a/src/server/core/github.ts b/src/server/core/github.ts
index 8238f9c..edd9526 100644
--- a/src/server/core/github.ts
+++ b/src/server/core/github.ts
@@ -116,6 +116,9 @@ function pemToArrayBuffer(pem: string) {
const base64 = pem
.replace(/-----BEGIN (RSA )?PRIVATE KEY-----/g, '')
.replace(/-----END (RSA )?PRIVATE KEY-----/g, '')
+ // Handle literal \n escape sequences (e.g. when the key is stored as a
+ // single-line string with \n instead of real newlines in wrangler secrets)
+ .replace(/\\n/g, '')
.replace(/\s+/g, '');
const binary = atob(base64);
From 5174d492e7779aba0b771fa34770bd466cb8656f Mon Sep 17 00:00:00 2001
From: Devarshi Shimpi
Date: Sun, 7 Jun 2026 22:16:54 +0530
Subject: [PATCH 6/7] fix: unescape literal \n sequences when reading secrets
from .dev.vars
---
scripts/setup-cloudflare.js | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/scripts/setup-cloudflare.js b/scripts/setup-cloudflare.js
index 11dfe2a..c87b88a 100644
--- a/scripts/setup-cloudflare.js
+++ b/scripts/setup-cloudflare.js
@@ -221,7 +221,10 @@ function getEnvVars() {
if (line.trim() && !line.startsWith('#')) {
const [key, ...values] = line.split('=');
if (key && values.length > 0) {
- env[key.trim()] = values.join('=').trim().replace(/^"|"$/g, '');
+ // Strip surrounding quotes, then unescape literal \n sequences
+ // (wrangler secrets must receive real newlines, not the two chars \ and n)
+ const raw = values.join('=').trim().replace(/^"|"$/g, '');
+ env[key.trim()] = raw.replace(/\\n/g, '\n');
}
}
}
From 1aeaab27f77b84ab2d767c4e287b6991d0307c75 Mon Sep 17 00:00:00 2001
From: Devarshi Shimpi
Date: Sun, 7 Jun 2026 22:53:59 +0530
Subject: [PATCH 7/7] refactor: color system, error handling, caching, and
migration improvements
---
scripts/migrate.mjs | 25 +++++---
src/client/app.css | 68 ++++++++++-----------
src/client/components/shared/jobs-table.tsx | 10 ++-
src/client/components/ui/select.tsx | 2 +
src/client/lib/api.ts | 6 +-
src/client/main.tsx | 31 +++++++++-
src/server/core/model-output.ts | 10 ++-
7 files changed, 96 insertions(+), 56 deletions(-)
diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs
index bd11b9f..c2088d5 100644
--- a/scripts/migrate.mjs
+++ b/scripts/migrate.mjs
@@ -402,11 +402,11 @@ async function normalizeRepoConfigs() {
}
console.log('Normalizing repo configs...');
- const functionName = 'codra_replace_deprecated_model_' + Date.now();
+ const functionName = 'codra_replace_deprecated_model';
- console.log(`Creating function: ${functionName}`);
+ console.log(`Creating function: pg_temp.${functionName}`);
await query(`
- CREATE FUNCTION public.${functionName}(input jsonb, old_value text, new_value text)
+ CREATE FUNCTION pg_temp.${functionName}(input jsonb, old_value text, new_value text)
RETURNS jsonb
LANGUAGE sql
IMMUTABLE
@@ -415,14 +415,14 @@ async function normalizeRepoConfigs() {
WHEN 'string' THEN CASE WHEN input #>> '{}' = old_value THEN to_jsonb(new_value) ELSE input END
WHEN 'array' THEN COALESCE(
(
- SELECT jsonb_agg(public.${functionName}(value, old_value, new_value) ORDER BY ord)
+ SELECT jsonb_agg(pg_temp.${functionName}(value, old_value, new_value) ORDER BY ord)
FROM jsonb_array_elements(input) WITH ORDINALITY AS item(value, ord)
),
'[]'::jsonb
)
WHEN 'object' THEN COALESCE(
(
- SELECT jsonb_object_agg(key, public.${functionName}(value, old_value, new_value))
+ SELECT jsonb_object_agg(key, pg_temp.${functionName}(value, old_value, new_value))
FROM jsonb_each(input)
),
'{}'::jsonb
@@ -440,15 +440,15 @@ async function normalizeRepoConfigs() {
main_model = CASE WHEN main_model = $1 THEN $2 ELSE main_model END,
fallback_models = CASE
WHEN fallback_models IS NULL THEN NULL
- ELSE public.${functionName}(fallback_models, $1, $2)
+ ELSE pg_temp.${functionName}(fallback_models, $1, $2)
END,
size_overrides = CASE
WHEN size_overrides IS NULL THEN NULL
- ELSE public.${functionName}(size_overrides, $1, $2)
+ ELSE pg_temp.${functionName}(size_overrides, $1, $2)
END,
parsed_json = CASE
WHEN parsed_json IS NULL THEN NULL
- ELSE public.${functionName}(parsed_json, $1, $2)
+ ELSE pg_temp.${functionName}(parsed_json, $1, $2)
END
WHERE main_model = $1
OR fallback_models::text LIKE '%' || $1 || '%'
@@ -458,13 +458,16 @@ async function normalizeRepoConfigs() {
[kimiK25Model, kimiK26Model],
);
- console.log(`Dropping function: ${functionName}`);
- await query(`DROP FUNCTION IF EXISTS public.${functionName}(jsonb, text, text)`);
+ console.log(`Dropping function: pg_temp.${functionName}`);
+ await query(`DROP FUNCTION IF EXISTS pg_temp.${functionName}(jsonb, text, text)`);
console.log('Repo configs normalized.');
}
async function main() {
try {
+ console.log('Acquiring advisory lock...');
+ await query('SELECT pg_advisory_lock($1)', [migrationLockId]);
+
console.log('Starting database migrations...');
await ensureMigrationTable();
@@ -486,6 +489,8 @@ async function main() {
console.log('Database migrations are up to date.');
} finally {
+ console.log('Releasing advisory lock...');
+ await query('SELECT pg_advisory_unlock($1)', [migrationLockId]);
await sql.end();
}
}
diff --git a/src/client/app.css b/src/client/app.css
index b1677d2..d0e60cb 100644
--- a/src/client/app.css
+++ b/src/client/app.css
@@ -16,11 +16,11 @@
:root {
/* Surfaces - Pure & Crisp */
/* Surfaces - High-Contrast */
- --background: #f4f4f5;
+ --background: oklch(96.3% 0.003 286.3); /* #f4f4f5 */
--foreground: oklch(12% 0.02 115);
- --card: #ffffff;
+ --card: oklch(100% 0 0); /* #ffffff */
--card-foreground: oklch(12% 0.02 115);
- --popover: #ffffff;
+ --popover: oklch(100% 0 0);
--popover-foreground: oklch(12% 0.02 115);
/* Signature lime - darkened in light mode for AA accessibility on white */
@@ -28,22 +28,22 @@
--primary-foreground: oklch(100% 0 0); /* White text on the deeper green */
/* Secondary / muted - Zinc */
- --secondary: #f4f4f5;
- --secondary-foreground:#27272a;
- --muted: #f4f4f5;
- --muted-foreground: #71717a;
+ --secondary: oklch(96.3% 0.003 286.3);
+ --secondary-foreground:oklch(27.4% 0.006 286.3); /* #27272a */
+ --muted: oklch(96.3% 0.003 286.3);
+ --muted-foreground: oklch(55.1% 0.011 286.3); /* #71717a */
/* Accent - slightly darker zinc for visible hover on white popovers */
- --accent: #e4e4e7;
- --accent-foreground: #18181b;
+ --accent: oklch(90.9% 0.004 286.3); /* #e4e4e7 */
+ --accent-foreground: oklch(20.5% 0.005 286.3); /* #18181b */
/* Destructive */
--destructive: oklch(55% 0.22 25);
--destructive-foreground: oklch(100% 0 0);
/* Border / input / ring */
- --border: #e4e4e7;
- --input: #e4e4e7;
+ --border: oklch(90.9% 0.004 286.3);
+ --input: oklch(90.9% 0.004 286.3);
--ring: oklch(72% 0.22 115);
/* Radius */
@@ -71,9 +71,9 @@
--shadow-lg: 0 4px 16px -4px oklch(0% 0 0 / 0.04), 0 1px 6px -2px oklch(0% 0 0 / 0.03);
/* Code Blocks (Zinc) */
- --code-bg: #f4f4f5;
- --code-fg: #27272a;
- --code-border: #e4e4e7;
+ --code-bg: oklch(96.3% 0.003 286.3);
+ --code-fg: oklch(27.4% 0.006 286.3);
+ --code-border: oklch(90.9% 0.004 286.3);
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@@ -125,9 +125,9 @@
--shadow-lg: 0 12px 24px -4px oklch(0% 0 0 / 0.5), 0 4px 12px -2px oklch(0% 0 0 / 0.3);
/* Code Blocks (Zinc) */
- --code-bg: #18181b;
- --code-fg: #d4d4d8;
- --code-border: #27272a;
+ --code-bg: oklch(20.5% 0.005 286.3);
+ --code-fg: oklch(86.5% 0.005 286.3);
+ --code-border: oklch(27.4% 0.006 286.3);
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@@ -173,11 +173,11 @@
--color-info-bg: var(--info-bg);
--color-info-border: var(--info-border);
- --radius-sm: 4px;
- --radius-md: 8px;
- --radius-lg: 12px;
- --radius-xl: 18px;
- --radius-2xl: 32px;
+ --radius-sm: 0.25rem;
+ --radius-md: 0.5rem;
+ --radius-lg: 0.75rem;
+ --radius-xl: 1.125rem;
+ --radius-2xl: 2rem;
/* Fluid Typography Scale (Ratio: 1.25) */
--text-xs: 0.75rem;
@@ -656,21 +656,21 @@
}
.app-shell-content {
- --background: #f4f4f5;
- --card: #ffffff;
- --muted: #f4f4f5;
- --popover: #ffffff;
- --secondary: #f4f4f5;
- --border: #e4e4e7;
- --input: #e4e4e7;
+ --background: oklch(96.3% 0.003 286.3); /* #f4f4f5 */
+ --card: oklch(100% 0 0); /* #ffffff */
+ --muted: oklch(90.9% 0.004 286.3); /* #e4e4e7 */
+ --popover: oklch(100% 0 0);
+ --secondary: oklch(88.5% 0.004 286.3); /* #e2e2e6 */
+ --border: oklch(90.9% 0.004 286.3); /* #e4e4e7 */
+ --input: oklch(90.9% 0.004 286.3); /* #e4e4e7 */
}
.dark .app-shell-content {
- --background: #09090b;
- --card: #09090b;
- --muted: #09090b;
- --popover: #09090b;
- --secondary: #09090b;
+ --background: oklch(18% 0.018 115);
+ --card: oklch(18% 0.018 115);
+ --muted: oklch(22% 0.02 115);
+ --popover: oklch(18% 0.018 115);
+ --secondary: oklch(26% 0.02 115);
--border: oklch(22% 0.02 115);
--input: oklch(22% 0.02 115);
}
diff --git a/src/client/components/shared/jobs-table.tsx b/src/client/components/shared/jobs-table.tsx
index 59723bc..a078763 100644
--- a/src/client/components/shared/jobs-table.tsx
+++ b/src/client/components/shared/jobs-table.tsx
@@ -8,7 +8,7 @@ import {
import { StatusBadge } from '@client/components/ui/badge';
import { Skeleton } from '@client/components/shared/skeleton';
import { cn, fmtNumber } from '@client/lib/utils';
-import { useIsDarkMode } from '@client/hooks/use-is-dark-mode';
+
import type { JobSummary } from '@shared/schema';
type Column =
@@ -199,8 +199,7 @@ function JobMobileCard({ job, columns }: { job: JobSummary; columns: Column[] })
export function JobsTable({ jobs, loading, columns }: JobsTableProps) {
const cols: Column[] = columns ?? DEFAULT_COLUMNS;
const tableMinWidth = cols.length > 7 ? 'min-w-[980px]' : 'min-w-[720px]';
- const isDark = useIsDarkMode();
- const itemBg = isDark ? '#09090b' : '#fafafa';
+ const itemBgClass = 'bg-background';
return (
@@ -275,7 +274,7 @@ export function JobsTable({ jobs, loading, columns }: JobsTableProps) {
)}
>
-
+
{job.repo.slice(0, 2).toUpperCase()}
@@ -410,8 +409,7 @@ export function JobsTable({ jobs, loading, columns }: JobsTableProps) {
>
diff --git a/src/client/components/ui/select.tsx b/src/client/components/ui/select.tsx
index 1a409c7..0f419aa 100644
--- a/src/client/components/ui/select.tsx
+++ b/src/client/components/ui/select.tsx
@@ -44,6 +44,7 @@ export function Select({
triggerClassName,
triggerStyle,
leadingIcon,
+ variant = 'page',
}: SelectProps) {
const selectedOption = options.find((opt) => opt.value === value);
@@ -60,6 +61,7 @@ export function Select({
variant="outline"
className={cn(
'h-9 w-full justify-between px-3 py-2 text-sm font-normal transition-all focus-visible:ring-0 focus-visible:ring-offset-0',
+ variant === 'page' ? 'bg-card' : 'bg-muted/50',
!selectedOption && 'text-muted-foreground',
triggerClassName,
)}
diff --git a/src/client/lib/api.ts b/src/client/lib/api.ts
index b2ff1e8..baae7e7 100644
--- a/src/client/lib/api.ts
+++ b/src/client/lib/api.ts
@@ -119,6 +119,8 @@ async function requestWithMeta
(input: string, init?: RequestInit) {
}
let updatesEmailPromise: Promise | null = null;
+let updatesEmailFetchTime = 0;
+const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
export const api = {
getSession() {
@@ -130,7 +132,9 @@ export const api = {
});
},
getUpdatesEmailStatus() {
- if (!updatesEmailPromise) {
+ const now = Date.now();
+ if (!updatesEmailPromise || (now - updatesEmailFetchTime > CACHE_TTL)) {
+ updatesEmailFetchTime = now;
updatesEmailPromise = request('/api/auth/updates-email').catch((err) => {
updatesEmailPromise = null;
throw err;
diff --git a/src/client/main.tsx b/src/client/main.tsx
index 7e40f75..0bb5880 100644
--- a/src/client/main.tsx
+++ b/src/client/main.tsx
@@ -51,10 +51,35 @@ function ToasterWrapper() {
);
}
+class ErrorBoundary extends React.Component<{ fallback?: React.ReactNode, children: React.ReactNode }, { error: Error | null }> {
+ constructor(props: { fallback?: React.ReactNode, children: React.ReactNode }) {
+ super(props);
+ this.state = { error: null };
+ }
+ static getDerivedStateFromError(error: Error) { return { error }; }
+ componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
+ console.error("ErrorBoundary caught an error:", error, errorInfo);
+ }
+ render() {
+ if (this.state.error) {
+ if (this.props.fallback) return this.props.fallback;
+ return (
+
+
An error occurred rendering this component:
+
{this.state.error.toString()}
+
+ );
+ }
+ return this.props.children;
+ }
+}
+
const withSuspense = (Component: React.ComponentType, isFullPage = false) => (
- }>
-
-
+
+ }>
+
+
+
);
const router = createBrowserRouter([
diff --git a/src/server/core/model-output.ts b/src/server/core/model-output.ts
index 3c14633..c44f255 100644
--- a/src/server/core/model-output.ts
+++ b/src/server/core/model-output.ts
@@ -134,8 +134,11 @@ function extractJson(raw: string) {
}
}
- // Truncated - return everything from first brace
- return raw.slice(firstBrace).trim();
+ // Truncated JSON: the closing brace(s) are missing. Append them so jsonrepair
+ // has a structurally complete (though incomplete-content) object to work with.
+ const partial = raw.slice(firstBrace).trim();
+ const closing = '}'.repeat(Math.max(1, stack));
+ return `${partial}${closing}`;
}
return raw.trim();
@@ -260,8 +263,11 @@ export function parseFileReviewResponse(raw: string, file: FileDiff): {
throw new Error('Model response did not contain review JSON keys.');
}
} catch (e) {
+ // Log a prefix of the raw response so we can diagnose what the model returned
+ // without bloating logs with 10k+ char dumps.
logger.error('Failed to extract JSON from model response', {
rawLength: raw.length,
+ rawPrefix: raw.slice(0, 500),
error: e instanceof Error ? e.message : String(e),
});
throw new Error('Could not find JSON root in model response.');