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/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..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", @@ -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/migrate.mjs b/scripts/migrate.mjs index 80b22bf..c2088d5 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'; + + console.log(`Creating function: pg_temp.${functionName}`); await query(` - CREATE OR REPLACE FUNCTION pg_temp.codra_replace_deprecated_model(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 @@ -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(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, pg_temp.codra_replace_deprecated_model(value, old_value, new_value)) + SELECT jsonb_object_agg(key, pg_temp.${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 pg_temp.${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 pg_temp.${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 pg_temp.${functionName}(parsed_json, $1, $2) END WHERE main_model = $1 OR fallback_models::text LIKE '%' || $1 || '%' @@ -453,12 +458,17 @@ async function normalizeRepoConfigs() { [kimiK25Model, kimiK26Model], ); - await query('DROP FUNCTION IF EXISTS pg_temp.codra_replace_deprecated_model(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() { - await query('SELECT pg_advisory_lock($1)', [migrationLockId]); try { + console.log('Acquiring advisory lock...'); + await query('SELECT pg_advisory_lock($1)', [migrationLockId]); + + console.log('Starting database migrations...'); await ensureMigrationTable(); const migrationFiles = (await readdir(migrationsDir)) @@ -472,12 +482,14 @@ 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 { + console.log('Releasing advisory lock...'); await query('SELECT pg_advisory_unlock($1)', [migrationLockId]); await sql.end(); } diff --git a/scripts/setup-cloudflare.js b/scripts/setup-cloudflare.js new file mode 100644 index 0000000..c87b88a --- /dev/null +++ b/scripts/setup-cloudflare.js @@ -0,0 +1,546 @@ +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) { + // 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'); + } + } + } + } + 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); +}); diff --git a/src/client/app.css b/src/client/app.css index a09fe98..d0e60cb 100644 --- a/src/client/app.css +++ b/src/client/app.css @@ -16,38 +16,38 @@ :root { /* Surfaces - Pure & Crisp */ /* Surfaces - High-Contrast */ - --background: #ffffff; + --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 */ --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: 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 */ - --accent: oklch(94% 0.03 115); - --accent-foreground: oklch(18% 0.016 115); + /* Accent - slightly darker zinc for visible hover on white popovers */ + --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: oklch(88% 0.008 115); - --input: oklch(88% 0.008 115); + --border: oklch(90.9% 0.004 286.3); + --input: oklch(90.9% 0.004 286.3); --ring: oklch(72% 0.22 115); /* Radius */ - --radius: 0.5rem; + --radius: 0.75rem; --sidebar-width: 240px; --sidebar-collapsed-width: 72px; @@ -66,14 +66,14 @@ --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; - --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: 2px; - --radius-md: 4px; - --radius-lg: 8px; - --radius-xl: 16px; - --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; @@ -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,19 +656,23 @@ } .app-shell-content { - --background: #ffffff; - --card: #ffffff; - --muted: #ffffff; - --popover: #ffffff; - --secondary: #ffffff; + --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); } .dashboard-sidebar-divider { 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/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/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..a078763 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 type { JobSummary } from '@shared/schema'; type Column = @@ -40,8 +41,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]', @@ -198,6 +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 itemBgClass = 'bg-background'; return (
@@ -262,7 +264,7 @@ export function JobsTable({ jobs, loading, columns }: JobsTableProps) { return ( {cols.includes('repo') && (
- + {job.repo.slice(0, 2).toUpperCase()}
@@ -293,23 +295,23 @@ export function JobsTable({ jobs, loading, columns }: JobsTableProps) { {cols.includes('pr') && ( -
+
-
-
+
+
#{job.prNumber} {job.prTitle ?? 'Untitled PR'} @@ -407,7 +409,7 @@ 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 ( + <> + + + + ); +} 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, opt.value === value); @@ -49,10 +60,12 @@ export function Select({ - + load(true)} + refreshing={refreshing} + /> } /> diff --git a/src/client/pages/landing.tsx b/src/client/pages/landing.tsx index 86d4233..4818b57 100644 --- a/src/client/pages/landing.tsx +++ b/src/client/pages/landing.tsx @@ -3,63 +3,86 @@ 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 ── */} +
Codra - +
+ + Sign in + + + +
- {/* Body */} -
+ {/* ── Body ── */} +
- {/* Left — Identity & CTA */} -
+ {/* Left — Hero */} +
-
-
-

- AI code review
- on every PR. +
+
+

+ 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.

-
{/* 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 ( -
+
-