vibe-roast 0.98.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +418 -0
- package/assests/README.md +43 -0
- package/assests/badges/.gitkeep +0 -0
- package/assests/badges/01-builder/builder-badge.png +0 -0
- package/assests/badges/01-builder/builder-badge.svg +4 -0
- package/assests/badges/02-debugger/debugger-badge.png +0 -0
- package/assests/badges/02-debugger/debugger-badge.svg +4 -0
- package/assests/badges/03-architect/architect-badge.png +0 -0
- package/assests/badges/03-architect/architect-badge.svg +4 -0
- package/assests/badges/04-codex-believer/codex-believer-badge.png +0 -0
- package/assests/badges/04-codex-believer/codex-believer-badge.svg +4 -0
- package/assests/badges/05-prompt-priest/prompt-priest-badge.png +0 -0
- package/assests/badges/05-prompt-priest/prompt-priest-badge.svg +4 -0
- package/assests/badges/06-tab-hoarder/tab-hoarder-badge.png +0 -0
- package/assests/badges/06-tab-hoarder/tab-hoarder-badge.svg +4 -0
- package/assests/badges/07-context-maxxer/context-maxxer-badge.png +0 -0
- package/assests/badges/07-context-maxxer/context-maxxer-badge.svg +4 -0
- package/assests/badges/08-yolo-shipper/yolo-shipper-badge.png +0 -0
- package/assests/badges/08-yolo-shipper/yolo-shipper-badge.svg +4 -0
- package/assests/banners/.gitkeep +0 -0
- package/assests/banners/home-hero.png +0 -0
- package/assests/banners/home-hero.svg +3 -0
- package/assests/banners/result-header.png +0 -0
- package/assests/banners/result-header.svg +3 -0
- package/assests/banners/share-strip.png +0 -0
- package/assests/banners/share-strip.svg +3 -0
- package/assests/characters/01-builder/builder-card.sbti-archive.svg +5 -0
- package/assests/characters/01-builder/builder-card.svg +3 -0
- package/assests/characters/01-builder/builder-character.sbti-archive.svg +3 -0
- package/assests/characters/01-builder/builder-character.svg +3 -0
- package/assests/characters/01-builder/builder-figure.png +0 -0
- package/assests/characters/01-builder/builder-mascot.png +0 -0
- package/assests/characters/02-debugger/debugger-card.sbti-archive.svg +5 -0
- package/assests/characters/02-debugger/debugger-card.svg +3 -0
- package/assests/characters/02-debugger/debugger-character.sbti-archive.svg +3 -0
- package/assests/characters/02-debugger/debugger-character.svg +3 -0
- package/assests/characters/02-debugger/debugger-figure.png +0 -0
- package/assests/characters/02-debugger/debugger-mascot.png +0 -0
- package/assests/characters/03-architect/architect-card.sbti-archive.svg +5 -0
- package/assests/characters/03-architect/architect-card.svg +3 -0
- package/assests/characters/03-architect/architect-character.sbti-archive.svg +3 -0
- package/assests/characters/03-architect/architect-character.svg +3 -0
- package/assests/characters/03-architect/architect-figure.png +0 -0
- package/assests/characters/03-architect/architect-mascot.png +0 -0
- package/assests/characters/04-codex-believer/codex-believer-card.sbti-archive.svg +5 -0
- package/assests/characters/04-codex-believer/codex-believer-card.svg +3 -0
- package/assests/characters/04-codex-believer/codex-believer-character.sbti-archive.svg +3 -0
- package/assests/characters/04-codex-believer/codex-believer-character.svg +3 -0
- package/assests/characters/04-codex-believer/codex-believer-figure.png +0 -0
- package/assests/characters/04-codex-believer/codex-believer-mascot.png +0 -0
- package/assests/characters/05-prompt-priest/prompt-priest-card.sbti-archive.svg +5 -0
- package/assests/characters/05-prompt-priest/prompt-priest-card.svg +3 -0
- package/assests/characters/05-prompt-priest/prompt-priest-character.sbti-archive.svg +3 -0
- package/assests/characters/05-prompt-priest/prompt-priest-character.svg +3 -0
- package/assests/characters/05-prompt-priest/prompt-priest-figure.png +0 -0
- package/assests/characters/05-prompt-priest/prompt-priest-mascot.png +0 -0
- package/assests/characters/06-tab-hoarder/tab-hoarder-card.sbti-archive.svg +5 -0
- package/assests/characters/06-tab-hoarder/tab-hoarder-card.svg +3 -0
- package/assests/characters/06-tab-hoarder/tab-hoarder-character.sbti-archive.svg +3 -0
- package/assests/characters/06-tab-hoarder/tab-hoarder-character.svg +3 -0
- package/assests/characters/06-tab-hoarder/tab-hoarder-figure.png +0 -0
- package/assests/characters/06-tab-hoarder/tab-hoarder-mascot.png +0 -0
- package/assests/characters/07-context-maxxer/context-maxxer-card.sbti-archive.svg +5 -0
- package/assests/characters/07-context-maxxer/context-maxxer-card.svg +3 -0
- package/assests/characters/07-context-maxxer/context-maxxer-character.sbti-archive.svg +3 -0
- package/assests/characters/07-context-maxxer/context-maxxer-character.svg +3 -0
- package/assests/characters/07-context-maxxer/context-maxxer-figure.png +0 -0
- package/assests/characters/07-context-maxxer/context-maxxer-mascot.png +0 -0
- package/assests/characters/08-yolo-shipper/yolo-shipper-card.sbti-archive.svg +5 -0
- package/assests/characters/08-yolo-shipper/yolo-shipper-card.svg +3 -0
- package/assests/characters/08-yolo-shipper/yolo-shipper-character.sbti-archive.svg +3 -0
- package/assests/characters/08-yolo-shipper/yolo-shipper-character.svg +3 -0
- package/assests/characters/08-yolo-shipper/yolo-shipper-figure.png +0 -0
- package/assests/characters/08-yolo-shipper/yolo-shipper-mascot.png +0 -0
- package/assests/characters-vibe-types/aosf-strategy-shipper/strategy-shipper-figure.png +0 -0
- package/assests/characters-vibe-types/aosx-agent-believer/agent-believer-figure.png +0 -0
- package/assests/characters-vibe-types/aovf-systems-architect/systems-architect-figure.png +0 -0
- package/assests/characters-vibe-types/aovx-context-cartographer/context-cartographer-figure.png +0 -0
- package/assests/characters-vibe-types/apsf-diagram-sprinter/diagram-sprinter-figure.png +0 -0
- package/assests/characters-vibe-types/apsx-infinite-planner/infinite-planner-figure.png +0 -0
- package/assests/characters-vibe-types/apvf-architect/architect-figure.png +0 -0
- package/assests/characters-vibe-types/apvx-prompt-priest/prompt-priest-figure.png +0 -0
- package/assests/characters-vibe-types/mosf-yolo-shipper/yolo-shipper-figure.png +0 -0
- package/assests/characters-vibe-types/mosx-agent-commander/agent-commander-figure.png +0 -0
- package/assests/characters-vibe-types/movf-agent-engineer/agent-engineer-figure.png +0 -0
- package/assests/characters-vibe-types/movx-systems-wrangler/systems-wrangler-figure.png +0 -0
- package/assests/characters-vibe-types/mpsf-builder/builder-figure.png +0 -0
- package/assests/characters-vibe-types/mpsx-tab-hoarder/tab-hoarder-figure.png +0 -0
- package/assests/characters-vibe-types/mpvf-debugger/debugger-figure.png +0 -0
- package/assests/characters-vibe-types/mpvx-context-maxxer/context-maxxer-figure.png +0 -0
- package/assests/live-report.html +1403 -0
- package/assests/notes/live-inspect-summary.json +3487 -0
- package/assests/scripts/compose-banners.py +148 -0
- package/assests/scripts/live-report-boot.js +2 -0
- package/assests/scripts/profiles.json +104 -0
- package/assests/scripts/qa-faces.mjs +72 -0
- package/assests/scripts/roast-extras.js +619 -0
- package/assests/scripts/score-engine.js +62 -0
- package/assests/scripts/split-badges.py +49 -0
- package/assests/scripts/split-reference.py +38 -0
- package/assests/scripts/sync-banners.mjs +45 -0
- package/assests/scripts/sync-cards.mjs +50 -0
- package/assests/scripts/sync-raster.mjs +118 -0
- package/assests/scripts/vibe-types.json +22 -0
- package/assests/source/design-system.md +50 -0
- package/assests/source/prompts/01-builder.md +19 -0
- package/assests/source/prompts/02-debugger.md +17 -0
- package/assests/source/prompts/03-architect.md +16 -0
- package/assests/source/prompts/04-codex-believer.md +13 -0
- package/assests/source/prompts/05-prompt-priest.md +13 -0
- package/assests/source/prompts/06-tab-hoarder.md +13 -0
- package/assests/source/prompts/07-context-maxxer.md +13 -0
- package/assests/source/prompts/08-yolo-shipper.md +13 -0
- package/assests/source/references/sbti-style-reference-board.png +0 -0
- package/assests/source/references/vibe-badges-v6-board.png +0 -0
- package/assests/source/references/vibe-mascots-v4-reference.png +0 -0
- package/assests/source/references/vibe-profiles-v5-board-a.png +0 -0
- package/assests/source/references/vibe-profiles-v5-board-b.png +0 -0
- package/assests/source/vibe-types-visual-brief.md +38 -0
- package/bin/hook.js +121 -0
- package/bin/vibe-roast.js +98 -0
- package/bin/vibe-wrapper.js +4 -0
- package/dashboard/dist/assets/index-DlBycTcO.css +1 -0
- package/dashboard/dist/assets/index-t8m64Hm9.js +168 -0
- package/dashboard/dist/index.html +32 -0
- package/package.json +58 -0
- package/src/extract/environment.js +160 -0
- package/src/extract/phrase-stats.js +684 -0
- package/src/extract/prompt-analysis.js +368 -0
- package/src/extract/text.js +30 -0
- package/src/hooks/install.js +184 -0
- package/src/inspect.js +261 -0
- package/src/lib/activity-metrics.js +238 -0
- package/src/lib/agent-score.js +187 -0
- package/src/lib/ai-roast.js +446 -0
- package/src/lib/dates.js +43 -0
- package/src/lib/hashtags.js +150 -0
- package/src/lib/jsonl.js +48 -0
- package/src/lib/load-env.js +59 -0
- package/src/lib/roast-evidence.js +186 -0
- package/src/lib/roast-snapshot.js +58 -0
- package/src/server.js +644 -0
- package/src/sources/aider.js +150 -0
- package/src/sources/amazonq.js +113 -0
- package/src/sources/antigravity.js +147 -0
- package/src/sources/claude.js +262 -0
- package/src/sources/cline.js +11 -0
- package/src/sources/codex.js +222 -0
- package/src/sources/common.js +133 -0
- package/src/sources/continue.js +67 -0
- package/src/sources/copilot.js +170 -0
- package/src/sources/cursor.js +238 -0
- package/src/sources/gemini.js +75 -0
- package/src/sources/index.js +61 -0
- package/src/sources/roo.js +11 -0
- package/src/sources/token-tracker.js +171 -0
- package/src/sources/vibe-tracker.js +79 -0
- package/src/sources/vscode-tasks.js +142 -0
- package/src/sources/windsurf.js +109 -0
- package/worker/README.md +96 -0
- package/worker/package-lock.json +1547 -0
- package/worker/package.json +20 -0
- package/worker/src/index.mjs +694 -0
- package/worker/wrangler.jsonc +40 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
const http = require("node:http");
|
|
2
|
+
const fs = require("node:fs");
|
|
3
|
+
const fsp = require("node:fs/promises");
|
|
4
|
+
const os = require("node:os");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
const { createHash, randomBytes, randomUUID } = require("node:crypto");
|
|
7
|
+
const { exec } = require("node:child_process");
|
|
8
|
+
const { inspectSources } = require("./inspect");
|
|
9
|
+
const { generateAiRoast, PROVIDERS } = require("./lib/ai-roast");
|
|
10
|
+
const { encodeRoastSnapshot } = require("./lib/roast-snapshot");
|
|
11
|
+
|
|
12
|
+
const PORT = process.env.PORT === undefined ? 7681 : Number(process.env.PORT);
|
|
13
|
+
const ROOT_DIR = path.join(__dirname, "..");
|
|
14
|
+
const DIST_DIR = path.join(ROOT_DIR, "dashboard", "dist");
|
|
15
|
+
const ASSETS_DIR = path.join(ROOT_DIR, "assests");
|
|
16
|
+
const DEFAULT_GITHUB_CLIENT_ID = "Iv23li5jqHs7pMarqWPZ";
|
|
17
|
+
const DEFAULT_GITHUB_AUTH_BROKER_URL = "https://auth.pinktalk.online";
|
|
18
|
+
const GITHUB_OAUTH_FLOWS = new Map();
|
|
19
|
+
const GITHUB_SESSION_COOKIE = "vibe_wrapper_github_session";
|
|
20
|
+
const GITHUB_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
21
|
+
const MAX_JSON_BODY = 64 * 1024;
|
|
22
|
+
const FREE_AI_PROVIDER = "cloudflare";
|
|
23
|
+
const FREE_AI_MODEL = "@cf/qwen/qwen3-30b-a3b-fp8";
|
|
24
|
+
|
|
25
|
+
function githubClientId() {
|
|
26
|
+
return process.env.VIBE_WRAPPER_GITHUB_CLIENT_ID || DEFAULT_GITHUB_CLIENT_ID;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function githubAuthConfig() {
|
|
30
|
+
const explicitBrokerUrl = String(process.env.VIBE_WRAPPER_AUTH_BROKER_URL || "").replace(/\/+$/, "");
|
|
31
|
+
if (explicitBrokerUrl) {
|
|
32
|
+
return { configured: true, mode: "broker", brokerUrl: explicitBrokerUrl };
|
|
33
|
+
}
|
|
34
|
+
if (process.env.VIBE_WRAPPER_GITHUB_CLIENT_SECRET) {
|
|
35
|
+
return {
|
|
36
|
+
configured: true,
|
|
37
|
+
mode: "direct",
|
|
38
|
+
clientId: githubClientId(),
|
|
39
|
+
clientSecret: process.env.VIBE_WRAPPER_GITHUB_CLIENT_SECRET,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
configured: true,
|
|
44
|
+
mode: "broker",
|
|
45
|
+
brokerUrl: DEFAULT_GITHUB_AUTH_BROKER_URL,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function githubSessionFile() {
|
|
50
|
+
return process.env.VIBE_WRAPPER_GITHUB_SESSION_FILE
|
|
51
|
+
|| path.join(os.homedir(), ".vibe-wrapper", "github-auth.json");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function base64Url(bytes) {
|
|
55
|
+
return Buffer.from(bytes).toString("base64url");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function requestOrigin(req) {
|
|
59
|
+
const host = String(req.headers.host || "");
|
|
60
|
+
if (!/^(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/i.test(host)) {
|
|
61
|
+
throw new Error("GitHub OAuth is only available from the local Vibe Roaster server");
|
|
62
|
+
}
|
|
63
|
+
return `http://${host}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function githubCallbackUrl(req) {
|
|
67
|
+
return process.env.VIBE_WRAPPER_GITHUB_CALLBACK_URL
|
|
68
|
+
|| `${requestOrigin(req)}/api/auth/github/callback`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseCookies(req) {
|
|
72
|
+
return Object.fromEntries(
|
|
73
|
+
String(req.headers.cookie || "")
|
|
74
|
+
.split(";")
|
|
75
|
+
.map((item) => item.trim())
|
|
76
|
+
.filter(Boolean)
|
|
77
|
+
.map((item) => {
|
|
78
|
+
const separator = item.indexOf("=");
|
|
79
|
+
return separator === -1
|
|
80
|
+
? [decodeURIComponent(item), ""]
|
|
81
|
+
: [decodeURIComponent(item.slice(0, separator)), decodeURIComponent(item.slice(separator + 1))];
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function readGithubSession(req) {
|
|
87
|
+
const sessionId = parseCookies(req)[GITHUB_SESSION_COOKIE];
|
|
88
|
+
if (!sessionId) return null;
|
|
89
|
+
try {
|
|
90
|
+
const session = JSON.parse(await fsp.readFile(githubSessionFile(), "utf8"));
|
|
91
|
+
if (session?.sessionId !== sessionId || Number(session?.expiresAt) <= Date.now()) return null;
|
|
92
|
+
return session;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function writeGithubSession(session) {
|
|
99
|
+
const file = githubSessionFile();
|
|
100
|
+
await fsp.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
101
|
+
const tempFile = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
102
|
+
await fsp.writeFile(tempFile, `${JSON.stringify(session, null, 2)}\n`, { mode: 0o600 });
|
|
103
|
+
await fsp.rename(tempFile, file);
|
|
104
|
+
await fsp.chmod(file, 0o600);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function clearGithubSession(req) {
|
|
108
|
+
const sessionId = parseCookies(req)[GITHUB_SESSION_COOKIE];
|
|
109
|
+
if (!sessionId) return;
|
|
110
|
+
try {
|
|
111
|
+
const session = JSON.parse(await fsp.readFile(githubSessionFile(), "utf8"));
|
|
112
|
+
if (session?.sessionId === sessionId) await fsp.unlink(githubSessionFile());
|
|
113
|
+
} catch {
|
|
114
|
+
// An absent or malformed local session is already logged out.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function sessionCookie(sessionId, maxAge = Math.floor(GITHUB_SESSION_TTL_MS / 1000)) {
|
|
119
|
+
return `${GITHUB_SESSION_COOKIE}=${encodeURIComponent(sessionId)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const MIME = {
|
|
123
|
+
".html": "text/html; charset=utf-8",
|
|
124
|
+
".js": "application/javascript; charset=utf-8",
|
|
125
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
126
|
+
".css": "text/css; charset=utf-8",
|
|
127
|
+
".svg": "image/svg+xml",
|
|
128
|
+
".png": "image/png",
|
|
129
|
+
".jpg": "image/jpeg",
|
|
130
|
+
".jpeg": "image/jpeg",
|
|
131
|
+
".webp": "image/webp",
|
|
132
|
+
".ico": "image/x-icon",
|
|
133
|
+
".json": "application/json; charset=utf-8",
|
|
134
|
+
".md": "text/markdown; charset=utf-8",
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
function parseQuery(url) {
|
|
138
|
+
const q = new URL(url, "http://localhost").searchParams;
|
|
139
|
+
return {
|
|
140
|
+
from: q.get("from") || undefined,
|
|
141
|
+
to: q.get("to") || undefined,
|
|
142
|
+
sources: q.get("sources") || undefined,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function sendJson(res, status, payload) {
|
|
147
|
+
res.writeHead(status, {
|
|
148
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
149
|
+
"Cache-Control": "no-store",
|
|
150
|
+
});
|
|
151
|
+
res.end(JSON.stringify(payload));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function readJson(req) {
|
|
155
|
+
return new Promise((resolve, reject) => {
|
|
156
|
+
let raw = "";
|
|
157
|
+
req.setEncoding("utf8");
|
|
158
|
+
req.on("data", (chunk) => {
|
|
159
|
+
raw += chunk;
|
|
160
|
+
if (raw.length > MAX_JSON_BODY) {
|
|
161
|
+
reject(new Error("Request body is too large"));
|
|
162
|
+
req.destroy();
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
req.on("end", () => {
|
|
166
|
+
try {
|
|
167
|
+
resolve(raw ? JSON.parse(raw) : {});
|
|
168
|
+
} catch {
|
|
169
|
+
reject(new Error("Invalid JSON body"));
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
req.on("error", reject);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function handleInspect(req, res) {
|
|
177
|
+
const query = parseQuery(req.url);
|
|
178
|
+
const report = await inspectSources(query);
|
|
179
|
+
report.vibe_profile.roast_source = "deterministic";
|
|
180
|
+
sendJson(res, 200, report);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function handleRoast(req, res) {
|
|
184
|
+
if (req.method !== "POST") {
|
|
185
|
+
sendJson(res, 405, { error: "Method not allowed" });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const body = await readJson(req);
|
|
189
|
+
const provider = String(body.provider || "");
|
|
190
|
+
const hosted = body.mode === "hosted" || provider === FREE_AI_PROVIDER;
|
|
191
|
+
if (hosted) {
|
|
192
|
+
const session = await readGithubSession(req);
|
|
193
|
+
const config = githubAuthConfig();
|
|
194
|
+
if (!session?.brokerSessionToken || session.mode !== "broker" || config.mode !== "broker") {
|
|
195
|
+
sendJson(res, 401, { error: "Sign in with GitHub to use the free AI roast" });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const evidence = body?.evidence;
|
|
199
|
+
if (!evidence || typeof evidence !== "object" || Array.isArray(evidence)) {
|
|
200
|
+
sendJson(res, 400, { error: "Roast evidence is required" });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
const generated = await generateAiRoast(evidence, {
|
|
205
|
+
provider: FREE_AI_PROVIDER,
|
|
206
|
+
apiKey: session.brokerSessionToken,
|
|
207
|
+
baseUrl: `${config.brokerUrl}/v1`,
|
|
208
|
+
model: FREE_AI_MODEL,
|
|
209
|
+
requestHeaders: {
|
|
210
|
+
"X-Vibe-Roast-Snapshot": encodeRoastSnapshot(evidence),
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
sendJson(res, 200, { generated });
|
|
214
|
+
} catch (error) {
|
|
215
|
+
const status = Number(error?.status);
|
|
216
|
+
sendJson(res, status === 401 || status === 429 ? status : 502, {
|
|
217
|
+
error: error?.message || String(error),
|
|
218
|
+
fallback_available: true,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (!PROVIDERS[provider]) {
|
|
224
|
+
sendJson(res, 400, { error: "Unsupported AI provider" });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const apiKey = String(body.apiKey || "");
|
|
229
|
+
if (!apiKey) {
|
|
230
|
+
sendJson(res, 400, { error: "API key is required" });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const evidence = body?.evidence;
|
|
235
|
+
if (!evidence || typeof evidence !== "object" || Array.isArray(evidence)) {
|
|
236
|
+
sendJson(res, 400, { error: "Roast evidence is required" });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
const generated = await generateAiRoast(evidence, {
|
|
241
|
+
provider,
|
|
242
|
+
apiKey,
|
|
243
|
+
baseUrl: body.baseUrl || undefined,
|
|
244
|
+
model: body.model || undefined,
|
|
245
|
+
});
|
|
246
|
+
sendJson(res, 200, { generated });
|
|
247
|
+
} catch (error) {
|
|
248
|
+
sendJson(res, 502, {
|
|
249
|
+
error: error?.message || String(error),
|
|
250
|
+
fallback_available: true,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function handleProviderCatalog(_req, res) {
|
|
256
|
+
const githubAuth = githubAuthConfig();
|
|
257
|
+
sendJson(res, 200, {
|
|
258
|
+
providers: Object.values(PROVIDERS)
|
|
259
|
+
.filter(({ id }) => id !== FREE_AI_PROVIDER)
|
|
260
|
+
.map(({ id, protocol, baseUrl, model }) => ({
|
|
261
|
+
id,
|
|
262
|
+
protocol,
|
|
263
|
+
baseUrl,
|
|
264
|
+
model,
|
|
265
|
+
})),
|
|
266
|
+
hosted: {
|
|
267
|
+
configured: githubAuth.configured && githubAuth.mode === "broker",
|
|
268
|
+
provider: FREE_AI_PROVIDER,
|
|
269
|
+
model: FREE_AI_MODEL,
|
|
270
|
+
auth: "github",
|
|
271
|
+
},
|
|
272
|
+
github: {
|
|
273
|
+
configured: githubAuth.configured,
|
|
274
|
+
mode: githubAuth.mode,
|
|
275
|
+
inferenceAvailable: false,
|
|
276
|
+
retirementDate: "2026-07-30",
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function githubUser(accessToken) {
|
|
282
|
+
const userResponse = await fetch("https://api.github.com/user", {
|
|
283
|
+
headers: {
|
|
284
|
+
Accept: "application/vnd.github+json",
|
|
285
|
+
Authorization: `Bearer ${accessToken}`,
|
|
286
|
+
"X-GitHub-Api-Version": "2026-03-10",
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
const userPayload = await userResponse.json().catch(() => ({}));
|
|
290
|
+
if (!userResponse.ok || !userPayload.login) {
|
|
291
|
+
throw new Error(userPayload.message || "GitHub returned no user identity");
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
login: userPayload.login,
|
|
295
|
+
avatarUrl: userPayload.avatar_url,
|
|
296
|
+
profileUrl: userPayload.html_url,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function sendRedirect(res, location) {
|
|
301
|
+
res.writeHead(302, {
|
|
302
|
+
Location: location,
|
|
303
|
+
"Cache-Control": "no-store",
|
|
304
|
+
});
|
|
305
|
+
res.end();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function authCallbackPage(res, { ok, message, cookie }) {
|
|
309
|
+
const payload = JSON.stringify({
|
|
310
|
+
type: "vibe-wrapper:github-auth",
|
|
311
|
+
status: ok ? "connected" : "failed",
|
|
312
|
+
error: ok ? undefined : message,
|
|
313
|
+
}).replace(/</g, "\\u003c");
|
|
314
|
+
const headers = {
|
|
315
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
316
|
+
"Cache-Control": "no-store",
|
|
317
|
+
"Content-Security-Policy": "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'",
|
|
318
|
+
};
|
|
319
|
+
if (cookie) headers["Set-Cookie"] = cookie;
|
|
320
|
+
res.writeHead(ok ? 200 : 400, headers);
|
|
321
|
+
res.end(`<!doctype html>
|
|
322
|
+
<html><head><meta charset="utf-8"><title>Vibe Roaster · GitHub</title></head>
|
|
323
|
+
<body style="font:16px system-ui;padding:40px;text-align:center;background:#f8f5ef;color:#171717">
|
|
324
|
+
<p>${ok ? "GitHub connected. You can close this window." : "GitHub sign-in failed."}</p>
|
|
325
|
+
<script>
|
|
326
|
+
const payload = ${payload};
|
|
327
|
+
if (window.opener) window.opener.postMessage(payload, window.location.origin);
|
|
328
|
+
if (payload.status === "connected") window.setTimeout(() => window.close(), 350);
|
|
329
|
+
</script>
|
|
330
|
+
</body></html>`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function handleGithubOAuthStart(req, res) {
|
|
334
|
+
if (req.method !== "GET") {
|
|
335
|
+
sendJson(res, 405, { error: "Method not allowed" });
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const config = githubAuthConfig();
|
|
339
|
+
if (!config.configured) {
|
|
340
|
+
sendJson(res, 503, {
|
|
341
|
+
error: "GitHub OAuth is not configured. Set VIBE_WRAPPER_AUTH_BROKER_URL, or use a local VIBE_WRAPPER_GITHUB_CLIENT_SECRET for development.",
|
|
342
|
+
code: "github_not_configured",
|
|
343
|
+
});
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const state = base64Url(randomBytes(24));
|
|
348
|
+
const verifier = base64Url(randomBytes(48));
|
|
349
|
+
const challenge = base64Url(createHash("sha256").update(verifier).digest());
|
|
350
|
+
const callbackUrl = githubCallbackUrl(req);
|
|
351
|
+
GITHUB_OAUTH_FLOWS.set(state, {
|
|
352
|
+
mode: config.mode,
|
|
353
|
+
verifier,
|
|
354
|
+
callbackUrl,
|
|
355
|
+
expiresAt: Date.now() + 10 * 60 * 1000,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
if (config.mode === "broker") {
|
|
359
|
+
const url = new URL(`${config.brokerUrl}/oauth/github/start`);
|
|
360
|
+
url.searchParams.set("callback", callbackUrl);
|
|
361
|
+
url.searchParams.set("state", state);
|
|
362
|
+
sendRedirect(res, url.toString());
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const url = new URL("https://github.com/login/oauth/authorize");
|
|
367
|
+
url.searchParams.set("client_id", config.clientId);
|
|
368
|
+
url.searchParams.set("redirect_uri", callbackUrl);
|
|
369
|
+
url.searchParams.set("state", state);
|
|
370
|
+
url.searchParams.set("code_challenge", challenge);
|
|
371
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
372
|
+
sendRedirect(res, url.toString());
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function exchangeDirectGithubCode(config, flow, code) {
|
|
376
|
+
const response = await fetch("https://github.com/login/oauth/access_token", {
|
|
377
|
+
method: "POST",
|
|
378
|
+
headers: {
|
|
379
|
+
Accept: "application/json",
|
|
380
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
381
|
+
},
|
|
382
|
+
body: new URLSearchParams({
|
|
383
|
+
client_id: config.clientId,
|
|
384
|
+
client_secret: config.clientSecret,
|
|
385
|
+
code,
|
|
386
|
+
redirect_uri: flow.callbackUrl,
|
|
387
|
+
code_verifier: flow.verifier,
|
|
388
|
+
}),
|
|
389
|
+
});
|
|
390
|
+
const payload = await response.json().catch(() => ({}));
|
|
391
|
+
if (!response.ok || !payload.access_token) {
|
|
392
|
+
throw new Error(payload.error_description || payload.error || "GitHub token exchange failed");
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
user: await githubUser(payload.access_token),
|
|
396
|
+
accessToken: payload.access_token,
|
|
397
|
+
refreshToken: payload.refresh_token || null,
|
|
398
|
+
tokenExpiresAt: payload.expires_in ? Date.now() + Number(payload.expires_in) * 1000 : null,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function exchangeBrokerTicket(config, flow, ticket, state) {
|
|
403
|
+
const response = await fetch(`${config.brokerUrl}/oauth/github/exchange`, {
|
|
404
|
+
method: "POST",
|
|
405
|
+
headers: {
|
|
406
|
+
Accept: "application/json",
|
|
407
|
+
"Content-Type": "application/json",
|
|
408
|
+
},
|
|
409
|
+
body: JSON.stringify({ ticket, state, callback: flow.callbackUrl }),
|
|
410
|
+
});
|
|
411
|
+
const payload = await response.json().catch(() => ({}));
|
|
412
|
+
if (!response.ok || !payload.sessionToken || !payload.user?.login) {
|
|
413
|
+
throw new Error(payload.error || "GitHub auth broker exchange failed");
|
|
414
|
+
}
|
|
415
|
+
return {
|
|
416
|
+
user: payload.user,
|
|
417
|
+
brokerSessionToken: payload.sessionToken,
|
|
418
|
+
tokenExpiresAt: payload.expiresAt ? Date.parse(payload.expiresAt) : null,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function refreshDirectGithubSession(session) {
|
|
423
|
+
if (
|
|
424
|
+
session.mode !== "direct"
|
|
425
|
+
|| !session.refreshToken
|
|
426
|
+
|| !session.tokenExpiresAt
|
|
427
|
+
|| Number(session.tokenExpiresAt) > Date.now() + 60_000
|
|
428
|
+
) {
|
|
429
|
+
return session;
|
|
430
|
+
}
|
|
431
|
+
const config = githubAuthConfig();
|
|
432
|
+
if (!config.configured || config.mode !== "direct") return null;
|
|
433
|
+
const response = await fetch("https://github.com/login/oauth/access_token", {
|
|
434
|
+
method: "POST",
|
|
435
|
+
headers: {
|
|
436
|
+
Accept: "application/json",
|
|
437
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
438
|
+
},
|
|
439
|
+
body: new URLSearchParams({
|
|
440
|
+
client_id: config.clientId,
|
|
441
|
+
client_secret: config.clientSecret,
|
|
442
|
+
grant_type: "refresh_token",
|
|
443
|
+
refresh_token: session.refreshToken,
|
|
444
|
+
}),
|
|
445
|
+
});
|
|
446
|
+
const payload = await response.json().catch(() => ({}));
|
|
447
|
+
if (!response.ok || !payload.access_token) return null;
|
|
448
|
+
const refreshed = {
|
|
449
|
+
...session,
|
|
450
|
+
accessToken: payload.access_token,
|
|
451
|
+
refreshToken: payload.refresh_token || session.refreshToken,
|
|
452
|
+
tokenExpiresAt: payload.expires_in ? Date.now() + Number(payload.expires_in) * 1000 : null,
|
|
453
|
+
};
|
|
454
|
+
await writeGithubSession(refreshed);
|
|
455
|
+
return refreshed;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function handleGithubOAuthCallback(req, res) {
|
|
459
|
+
const url = new URL(req.url, requestOrigin(req));
|
|
460
|
+
const state = String(url.searchParams.get("state") || "");
|
|
461
|
+
const flow = GITHUB_OAUTH_FLOWS.get(state);
|
|
462
|
+
GITHUB_OAUTH_FLOWS.delete(state);
|
|
463
|
+
if (!flow || flow.expiresAt <= Date.now()) {
|
|
464
|
+
authCallbackPage(res, { ok: false, message: "GitHub sign-in expired. Start again from Vibe Roaster." });
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (url.searchParams.get("error")) {
|
|
468
|
+
authCallbackPage(res, {
|
|
469
|
+
ok: false,
|
|
470
|
+
message: url.searchParams.get("error_description") || url.searchParams.get("error"),
|
|
471
|
+
});
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
try {
|
|
476
|
+
const config = githubAuthConfig();
|
|
477
|
+
if (!config.configured || config.mode !== flow.mode) throw new Error("GitHub OAuth configuration changed during sign-in");
|
|
478
|
+
const identity = config.mode === "broker"
|
|
479
|
+
? await exchangeBrokerTicket(config, flow, String(url.searchParams.get("ticket") || ""), state)
|
|
480
|
+
: await exchangeDirectGithubCode(config, flow, String(url.searchParams.get("code") || ""));
|
|
481
|
+
const sessionId = base64Url(randomBytes(32));
|
|
482
|
+
const expiresAt = config.mode === "broker" && identity.tokenExpiresAt
|
|
483
|
+
? Math.min(identity.tokenExpiresAt, Date.now() + GITHUB_SESSION_TTL_MS)
|
|
484
|
+
: Date.now() + GITHUB_SESSION_TTL_MS;
|
|
485
|
+
await writeGithubSession({
|
|
486
|
+
sessionId,
|
|
487
|
+
mode: config.mode,
|
|
488
|
+
user: identity.user,
|
|
489
|
+
accessToken: identity.accessToken || undefined,
|
|
490
|
+
refreshToken: identity.refreshToken || undefined,
|
|
491
|
+
brokerSessionToken: identity.brokerSessionToken || undefined,
|
|
492
|
+
tokenExpiresAt: identity.tokenExpiresAt,
|
|
493
|
+
expiresAt,
|
|
494
|
+
createdAt: Date.now(),
|
|
495
|
+
});
|
|
496
|
+
authCallbackPage(res, {
|
|
497
|
+
ok: true,
|
|
498
|
+
cookie: sessionCookie(sessionId, Math.max(1, Math.floor((expiresAt - Date.now()) / 1000))),
|
|
499
|
+
});
|
|
500
|
+
} catch (error) {
|
|
501
|
+
authCallbackPage(res, { ok: false, message: error?.message || String(error) });
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function handleGithubSession(req, res) {
|
|
506
|
+
if (req.method === "DELETE") {
|
|
507
|
+
await clearGithubSession(req);
|
|
508
|
+
res.setHeader("Set-Cookie", sessionCookie("", 0));
|
|
509
|
+
sendJson(res, 200, { status: "signed_out" });
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (req.method !== "GET") {
|
|
513
|
+
sendJson(res, 405, { error: "Method not allowed" });
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
let session = await readGithubSession(req);
|
|
517
|
+
session = session ? await refreshDirectGithubSession(session) : null;
|
|
518
|
+
if (!session) {
|
|
519
|
+
sendJson(res, 200, { status: "anonymous", user: null });
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
sendJson(res, 200, {
|
|
523
|
+
status: "connected",
|
|
524
|
+
user: session.user,
|
|
525
|
+
mode: session.mode,
|
|
526
|
+
expiresAt: new Date(session.expiresAt).toISOString(),
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function sendFile(filePath, res, { spaFallback = false } = {}) {
|
|
531
|
+
const ext = path.extname(filePath);
|
|
532
|
+
const contentType = MIME[ext] || "application/octet-stream";
|
|
533
|
+
|
|
534
|
+
fs.readFile(filePath, (err, data) => {
|
|
535
|
+
if (err) {
|
|
536
|
+
if (err.code === "ENOENT" && spaFallback) {
|
|
537
|
+
fs.readFile(path.join(DIST_DIR, "index.html"), (err2, data2) => {
|
|
538
|
+
if (err2) {
|
|
539
|
+
res.writeHead(404);
|
|
540
|
+
res.end("Not Found");
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
544
|
+
res.end(data2);
|
|
545
|
+
});
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (err.code === "ENOENT") {
|
|
549
|
+
res.writeHead(404);
|
|
550
|
+
res.end("Not Found");
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
res.writeHead(500);
|
|
554
|
+
res.end("Internal Server Error");
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
res.writeHead(200, { "Content-Type": contentType });
|
|
558
|
+
res.end(data);
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function serveUnderRoot(req, res, rootDir, urlPrefix) {
|
|
563
|
+
const rawPath = decodeURIComponent(req.url.split("?")[0]);
|
|
564
|
+
const relative = rawPath.slice(urlPrefix.length) || "index.html";
|
|
565
|
+
const filePath = path.normalize(path.join(rootDir, relative));
|
|
566
|
+
if (!filePath.startsWith(rootDir + path.sep) && filePath !== rootDir) {
|
|
567
|
+
res.writeHead(403);
|
|
568
|
+
res.end("Forbidden");
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
sendFile(filePath, res);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function serveStatic(req, res) {
|
|
575
|
+
let filePath = req.url === "/" ? "/index.html" : req.url.split("?")[0];
|
|
576
|
+
filePath = path.join(DIST_DIR, filePath);
|
|
577
|
+
|
|
578
|
+
if (!filePath.startsWith(DIST_DIR + path.sep)) {
|
|
579
|
+
res.writeHead(403);
|
|
580
|
+
res.end("Forbidden");
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
sendFile(filePath, res, { spaFallback: true });
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function createServer() {
|
|
588
|
+
return http.createServer((req, res) => {
|
|
589
|
+
const route = req.url.split("?")[0];
|
|
590
|
+
let task;
|
|
591
|
+
if (route === "/api/inspect") task = handleInspect(req, res);
|
|
592
|
+
else if (route === "/api/roast") task = handleRoast(req, res);
|
|
593
|
+
else if (route === "/api/ai/providers") task = handleProviderCatalog(req, res);
|
|
594
|
+
else if (route === "/api/auth/github/start") task = handleGithubOAuthStart(req, res);
|
|
595
|
+
else if (route === "/api/auth/github/callback") task = handleGithubOAuthCallback(req, res);
|
|
596
|
+
else if (route === "/api/auth/github/session") task = handleGithubSession(req, res);
|
|
597
|
+
if (task) {
|
|
598
|
+
Promise.resolve(task).catch((error) => {
|
|
599
|
+
if (!res.headersSent) sendJson(res, 500, { error: error?.message || String(error) });
|
|
600
|
+
else res.end();
|
|
601
|
+
});
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (req.url.startsWith("/assests/") || req.url === "/assests") {
|
|
605
|
+
return serveUnderRoot(req, res, ASSETS_DIR, "/assests");
|
|
606
|
+
}
|
|
607
|
+
serveStatic(req, res);
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function openBrowser(url) {
|
|
612
|
+
if (process.env.VIBE_WRAPPER_NO_OPEN === "1") return;
|
|
613
|
+
const cmd = process.platform === "darwin" ? `open "${url}"`
|
|
614
|
+
: process.platform === "win32" ? `start "" "${url}"`
|
|
615
|
+
: `xdg-open "${url}"`;
|
|
616
|
+
exec(cmd, () => {});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function start() {
|
|
620
|
+
const server = createServer();
|
|
621
|
+
return new Promise((resolve) => {
|
|
622
|
+
server.listen(PORT, () => {
|
|
623
|
+
const address = server.address();
|
|
624
|
+
const port = typeof address === "object" && address ? address.port : PORT;
|
|
625
|
+
const url = `http://localhost:${port}`;
|
|
626
|
+
process.stderr.write(`vibe-roast result → ${url}\n`);
|
|
627
|
+
process.stderr.write(`static live report → ${url}/assests/live-report.html\n`);
|
|
628
|
+
openBrowser(url);
|
|
629
|
+
resolve(server);
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
module.exports = {
|
|
635
|
+
DEFAULT_GITHUB_AUTH_BROKER_URL,
|
|
636
|
+
DEFAULT_GITHUB_CLIENT_ID,
|
|
637
|
+
createServer,
|
|
638
|
+
githubAuthConfig,
|
|
639
|
+
githubCallbackUrl,
|
|
640
|
+
githubClientId,
|
|
641
|
+
githubSessionFile,
|
|
642
|
+
requestOrigin,
|
|
643
|
+
start,
|
|
644
|
+
};
|