Skip to content

Commit faf6a90

Browse files
committed
feat: wire MCP v1 + OAuth fixes + wizard flow
1 parent 951d3c3 commit faf6a90

12 files changed

Lines changed: 707 additions & 76 deletions

client/package-lock.json

Lines changed: 54 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"dependencies": {
1313
"react": "^19.1.1",
1414
"react-dom": "^19.1.1",
15+
"react-router-dom": "^7.9.4",
1516
"zustand": "^5.0.8"
1617
},
1718
"devDependencies": {

client/src/App.tsx

Lines changed: 37 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,41 @@
1-
import { useState } from 'react'
2-
import reactLogo from './assets/react.svg'
3-
import viteLogo from '/vite.svg'
4-
import './App.css'
1+
// src/App.tsx
2+
import { BrowserRouter, Routes, Route, Navigate, Link } from "react-router-dom";
3+
import ConnectPage from "./pages/ConnectPage";
4+
import ConfigurePage from "./pages/ConfigurePage";
5+
import SecretsPage from "./pages/SecretsPage";
6+
import DashboardPage from "./pages/DashboardPage";
7+
import { useRepoStore } from "./store/useRepoStore";
8+
import { usePipelineStore } from "./store/usePipelineStore";
59

6-
function App() {
7-
const [count, setCount] = useState(0)
10+
function NeedRepo({ children }: { children: JSX.Element }) {
11+
const { repo, branch } = useRepoStore();
12+
return !repo || !branch ? <Navigate to="/connect" replace /> : children;
13+
}
14+
function NeedPipeline({ children }: { children: JSX.Element }) {
15+
const { result } = usePipelineStore();
16+
return !result?.generated_yaml ? <Navigate to="/configure" replace /> : children;
17+
}
818

19+
export default function App() {
920
return (
10-
<>
11-
<div>
12-
<a href="https://vite.dev" target="_blank">
13-
<img src={viteLogo} className="logo" alt="Vite logo" />
14-
</a>
15-
<a href="https://react.dev" target="_blank">
16-
<img src={reactLogo} className="logo react" alt="React logo" />
17-
</a>
18-
</div>
19-
<h1>Vite + React</h1>
20-
<div className="card">
21-
<button onClick={() => setCount((count) => count + 1)}>
22-
count is {count}
23-
</button>
24-
<p>
25-
Edit <code>src/App.tsx</code> and save to test HMR
26-
</p>
27-
</div>
28-
<p className="read-the-docs">
29-
Click on the Vite and React logos to learn more
30-
</p>
31-
</>
32-
)
21+
<BrowserRouter>
22+
<header style={{ borderBottom: "1px solid #eee", padding: "12px" }}>
23+
<nav style={{ display: "flex", gap: 12, fontSize: 14 }}>
24+
<Link to="/connect">1 Connect</Link>
25+
<Link to="/configure">2 Configure</Link>
26+
<Link to="/secrets">3 Secrets</Link>
27+
<Link to="/dashboard">4 Dashboard</Link>
28+
</nav>
29+
</header>
30+
<main style={{ padding: 16, maxWidth: 960, margin: "0 auto" }}>
31+
<Routes>
32+
<Route path="/" element={<Navigate to="/connect" replace />} />
33+
<Route path="/connect" element={<ConnectPage />} />
34+
<Route path="/configure" element={<NeedRepo><ConfigurePage /></NeedRepo>} />
35+
<Route path="/secrets" element={<NeedRepo><NeedPipeline><SecretsPage /></NeedPipeline></NeedRepo>} />
36+
<Route path="/dashboard" element={<NeedRepo><DashboardPage /></NeedRepo>} />
37+
</Routes>
38+
</main>
39+
</BrowserRouter>
40+
);
3341
}
34-
35-
export default App

client/src/lib/api.ts

Lines changed: 192 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
const BASE = import.meta.env.VITE_API_BASE ?? "http://localhost:3333/api";
1+
export const BASE =
2+
import.meta.env.VITE_API_BASE ?? "http://localhost:3333/api";
3+
4+
// Derive the server base without any trailing "/api" for MCP calls
5+
const SERVER_BASE = BASE.replace(/\/api$/, "");
26

37
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
48
const res = await fetch(`${BASE}${path}`, {
@@ -7,26 +11,197 @@ async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
711
...opts,
812
});
913
const data = await res.json().catch(() => ({}));
10-
if (!res.ok) throw new Error(data?.error || res.statusText);
14+
if (!res.ok) throw new Error((data as any)?.error || res.statusText);
1115
return data as T;
1216
}
1317

18+
// Helper for MCP tool calls on the server at /mcp/v1/:tool_name
19+
async function mcp<T>(tool: string, input: Record<string, any> = {}): Promise<T> {
20+
const res = await fetch(`${SERVER_BASE}/mcp/v1/${encodeURIComponent(tool)}`, {
21+
method: "POST",
22+
headers: { "Content-Type": "application/json" },
23+
credentials: "include",
24+
body: JSON.stringify(input),
25+
});
26+
const payload = await res.json().catch(() => ({}));
27+
if (!res.ok || payload?.success === false) {
28+
const msg = payload?.error || res.statusText || "MCP error";
29+
throw new Error(msg);
30+
}
31+
return payload.data as T;
32+
}
33+
1434
export const api = {
15-
listRepos: () => request<{ repos: string[] }>("/mcp/repos"),
16-
listBranches: (repo: string) => request<{ branches: string[] }>(`/mcp/repos/${encodeURIComponent(repo)}/branches`),
17-
createPipeline: (payload: any) => request("/mcp/pipeline", { method: "POST", body: JSON.stringify(payload) }),
18-
listAwsRoles: () => request<{ roles: string[] }>("/mcp/oidc/roles"),
19-
openPr: (payload: any) => request("/mcp/pull-request", { method: "POST", body: JSON.stringify(payload) }),
20-
getConnections: (repo: string) => request(`/mcp/config/connections?repo=${encodeURIComponent(repo)}`),
21-
getSecretPresence: (repo: string, env: string) => request(`/mcp/config/secrets?repo=${encodeURIComponent(repo)}&env=${env}`),
22-
setSecret: (body: any) => request("/mcp/config/secret", { method: "POST", body: JSON.stringify(body) }),
23-
runPreflight: (body: any) => request("/mcp/config/preflight", { method: "POST", body: JSON.stringify(body) }),
24-
startDeploy: (body: any) => request("/mcp/deploy/start", { method: "POST", body: JSON.stringify(body) }),
25-
streamJob(jobId: string, onEvent: (e: any) => void) {
26-
const es = new EventSource(`${BASE}/mcp/jobs/${jobId}/events`, { withCredentials: true });
27-
es.onmessage = (evt) => onEvent(JSON.parse(evt.data));
28-
es.onerror = () => es.close();
29-
return () => es.close();
35+
// Pull repos via MCP repo_reader tool (mocked backend). Maps to simple string[] of full_name.
36+
async listRepos(): Promise<{ repos: string[] }> {
37+
const data = await mcp<{
38+
repositories: { name: string; full_name: string; branches?: string[] }[];
39+
}>("repo_reader", {});
40+
const repos = (data?.repositories ?? []).map((r) => r.full_name);
41+
return { repos };
42+
},
43+
44+
// Derive branches by calling repo_reader again and selecting the repo.
45+
async listBranches(repo: string): Promise<{ branches: string[] }> {
46+
const data = await mcp<{
47+
repositories: { name: string; full_name: string; branches?: string[] }[];
48+
}>("repo_reader", {});
49+
const item = (data?.repositories ?? []).find((r) => r.full_name === repo);
50+
return { branches: item?.branches ?? [] };
51+
},
52+
53+
// Generate pipeline via MCP pipeline_generator (mock). Assume provider 'aws' for now.
54+
async createPipeline(payload: any) {
55+
const { repo, branch, template = "node_app", options } = payload || {};
56+
const data = await mcp("pipeline_generator", {
57+
repo,
58+
branch,
59+
provider: "aws",
60+
template,
61+
options: options || {},
62+
});
63+
return data;
64+
},
65+
66+
// List AWS roles via MCP oidc_adapter and map to list of ARNs.
67+
async listAwsRoles(): Promise<{ roles: string[] }> {
68+
const data = await mcp<{ roles?: { name: string; arn: string }[] }>(
69+
"oidc_adapter",
70+
{ provider: "aws" }
71+
);
72+
const roles = (data.roles ?? []).map((r) => r.arn);
73+
return { roles };
74+
},
75+
76+
// Not implemented on server yet; keep API shape but throw a helpful error.
77+
async openPr(_payload: any) {
78+
throw new Error("openPr is not implemented on the server (no MCP tool)");
79+
},
80+
81+
// --- Mocked config/secrets endpoints for Secrets/Preflight flow ---
82+
async getConnections(_repo: string): Promise<{
83+
githubAppInstalled: boolean;
84+
githubRepoWriteOk: boolean;
85+
awsOidc: { connected: boolean; roleArn?: string; accountId?: string; region?: string };
86+
}> {
87+
// Try to fetch roles to populate a default role ARN
88+
let roleArn: string | undefined;
89+
try {
90+
const { roles } = await this.listAwsRoles();
91+
roleArn = roles[0];
92+
} catch {}
93+
return {
94+
githubAppInstalled: true,
95+
githubRepoWriteOk: true,
96+
awsOidc: { connected: !!roleArn, roleArn, accountId: "123456789012", region: "us-east-1" },
97+
};
98+
},
99+
100+
async getSecretPresence(repo: string, env: string): Promise<{ key: string; present: boolean }[]> {
101+
const required = ["GITHUB_TOKEN", "AWS_ROLE_ARN"];
102+
const store = readSecrets(repo, env);
103+
return required.map((k) => ({ key: k, present: !!store[k] }));
104+
},
105+
106+
async setSecret({ repo, env, key, value }: { repo: string; env: string; key: string; value: string }) {
107+
const store = readSecrets(repo, env);
108+
store[key] = value;
109+
writeSecrets(repo, env, store);
110+
return { ok: true } as const;
111+
},
112+
113+
async runPreflight({
114+
repo,
115+
env,
116+
aws,
117+
}: {
118+
repo: string;
119+
env: string;
120+
aws?: { roleArn?: string; region?: string };
121+
}): Promise<{ results: { label: string; ok: boolean; info?: string }[] }> {
122+
const connections = await this.getConnections(repo);
123+
const secrets = await this.getSecretPresence(repo, env);
124+
const hasGithubApp = connections.githubAppInstalled;
125+
const hasRepoWrite = connections.githubRepoWriteOk;
126+
const role = aws?.roleArn || connections.awsOidc.roleArn;
127+
const hasAws = !!role;
128+
const region = aws?.region || connections.awsOidc.region || "us-east-1";
129+
const s = Object.fromEntries(secrets.map((x) => [x.key, x.present] as const));
130+
131+
const results = [
132+
{ label: "GitHub App installed", ok: hasGithubApp },
133+
{ label: "Repo write access", ok: hasRepoWrite },
134+
{ label: "AWS OIDC configured", ok: hasAws, info: role },
135+
{ label: "Secret: GITHUB_TOKEN", ok: !!s.GITHUB_TOKEN },
136+
{ label: "Secret: AWS_ROLE_ARN", ok: !!s.AWS_ROLE_ARN, info: role },
137+
{ label: "AWS Region selected", ok: !!region, info: region },
138+
];
139+
return { results };
140+
},
141+
142+
// --- Mock deploy APIs for Dashboard ---
143+
async startDeploy({ repo, env }: { repo: string; env: string }) {
144+
const jobId = `job_${Math.random().toString(36).slice(2)}`;
145+
// Stash minimal job info in memory for the stream to reference
146+
JOBS.set(jobId, { repo, env, startedAt: Date.now() });
147+
return { jobId } as const;
148+
},
149+
150+
streamJob(
151+
jobId: string,
152+
onEvent: (e: { ts: string; level: "info" | "warn" | "error"; msg: string }) => void
153+
) {
154+
const meta = JOBS.get(jobId) || { repo: "?", env: "dev" };
155+
const steps = [
156+
`Authenticating to AWS (${meta.env})`,
157+
`Assuming role`,
158+
`Validating permissions`,
159+
`Building artifacts`,
160+
`Deploying ${meta.repo}`,
161+
`Verifying rollout`,
162+
`Done`
163+
];
164+
let i = 0;
165+
const timer = setInterval(() => {
166+
if (i >= steps.length) return;
167+
const level = i === steps.length - 1 ? "info" : "info";
168+
onEvent({ ts: new Date().toISOString(), level, msg: steps[i++] });
169+
if (i >= steps.length) clearInterval(timer);
170+
}, 800);
171+
return () => clearInterval(timer);
30172
},
31173
};
32174

175+
// Helper to start GitHub OAuth (server redirects back after callback)
176+
export function startGitHubOAuth(
177+
redirectTo: string = window.location.origin
178+
) {
179+
// Our server mounts OAuth at /auth/github/start and expects `redirect_to`
180+
const serverBase = BASE.replace(/\/api$/, "");
181+
const url = `${serverBase}/auth/github/start?redirect_to=${encodeURIComponent(
182+
redirectTo
183+
)}`;
184+
window.location.href = url;
185+
}
186+
187+
// --- Local storage helpers for mock secrets ---
188+
function secKey(repo: string, env: string) {
189+
return `secrets:${repo}:${env}`;
190+
}
191+
function readSecrets(repo: string, env: string): Record<string, string> {
192+
try {
193+
const raw = localStorage.getItem(secKey(repo, env));
194+
return raw ? JSON.parse(raw) : {};
195+
} catch {
196+
return {};
197+
}
198+
}
199+
function writeSecrets(repo: string, env: string, obj: Record<string, string>) {
200+
try {
201+
localStorage.setItem(secKey(repo, env), JSON.stringify(obj));
202+
} catch {}
203+
}
204+
205+
// in-memory job storage for mock deploys
206+
const JOBS: Map<string, any> = new Map();
207+

0 commit comments

Comments
 (0)