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.