mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 15:51:23 +00:00
feat: agent setup prompt for new projects
A "Set up with an AI agent" dialog that mints a write-scoped API key and renders a copyable prompt handing a coding agent everything needed to wire a repository into a Primora project: telemetry ingest for any language, db-connections for tracked databases, MCP/CLI agent access, and verification steps. Opens at the end of new-project onboarding; also reachable from the project dashboard and Projects page settings. Authored By: TDvorak <[email protected]> Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
dcc676bd52
commit
9dd6b40934
10 files changed
+381
-2
No files matched your search
@@ -29,6 +29,7 @@ import { authClient, fetchApiToken } from "./lib/auth-client";
|
||||
import { configureApiToken, errorMessage } from "./lib/api";
|
||||
import { isDemoMode, disableDemoMode, enableDemoMode, demoSession, demoService } from "./lib/demo-mode";
|
||||
import {
|
||||
AgentSetupModal,
|
||||
AppShell,
|
||||
CommandPaletteEnhanced,
|
||||
NetworkError,
|
||||
@@ -127,6 +128,7 @@ export default function App() {
|
||||
const [name, setName] = createSignal("");
|
||||
const [activeView, setActiveView] = createSignal<ViewType>("dashboard");
|
||||
const [showOnboarding, setShowOnboarding] = createSignal(false);
|
||||
const [agentSetupOpen, setAgentSetupOpen] = createSignal(false);
|
||||
const [paletteOpen, setPaletteOpen] = createSignal(false);
|
||||
const [theme, setTheme] = createSignal<"dark" | "light">(
|
||||
(localStorage.getItem("primora_theme") as "dark" | "light") ?? "dark",
|
||||
@@ -1696,6 +1698,7 @@ export default function App() {
|
||||
overview={projectOverview()}
|
||||
recentAudit={auditLogs()}
|
||||
onNavigate={(view) => setActiveView(view as ViewType)}
|
||||
onAgentSetup={() => setAgentSetupOpen(true)}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
@@ -1716,6 +1719,7 @@ export default function App() {
|
||||
onDeleteProject={deleteActiveProject}
|
||||
onSelectProject={setSelectedProjectID}
|
||||
onNavigateToDashboard={() => setActiveView("dashboard")}
|
||||
onAgentSetup={() => setAgentSetupOpen(true)}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
@@ -1966,6 +1970,23 @@ export default function App() {
|
||||
isOpen={showOnboarding()}
|
||||
projectName={activeProject()?.name ?? "Your project"}
|
||||
onClose={() => setShowOnboarding(false)}
|
||||
onDone={() => {
|
||||
setShowOnboarding(false);
|
||||
setAgentSetupOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<AgentSetupModal
|
||||
open={agentSetupOpen()}
|
||||
onClose={() => setAgentSetupOpen(false)}
|
||||
project={activeProject()}
|
||||
canManage={canUpdateProject() || isDemo}
|
||||
demoMode={isDemo}
|
||||
onKeyCreated={() => {
|
||||
void refetchAPIKeys();
|
||||
void refetchAuditLogs();
|
||||
void refreshProjectOverviewSnapshot();
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Show, createEffect, createSignal } from "solid-js";
|
||||
import { ProjectsService } from "@primora/api-client";
|
||||
import { demoService } from "../lib/demo-mode";
|
||||
import { buildAgentPrompt } from "../lib/agent-prompt";
|
||||
import { errorMessage } from "../lib/api";
|
||||
import { Modal, ModalFooter } from "./Modal";
|
||||
import { IconCheck, IconCopy, IconKey, IconRefresh } from "./Icons";
|
||||
|
||||
export interface AgentSetupProject {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
interface AgentSetupModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
project?: AgentSetupProject;
|
||||
canManage: boolean;
|
||||
demoMode: boolean;
|
||||
/** called after a key is minted so the app can refetch keys/audit/overview */
|
||||
onKeyCreated?: () => void;
|
||||
}
|
||||
|
||||
export function AgentSetupModal(props: AgentSetupModalProps) {
|
||||
const [prompt, setPrompt] = createSignal("");
|
||||
const [keyPrefix, setKeyPrefix] = createSignal("");
|
||||
const [busy, setBusy] = createSignal(false);
|
||||
const [error, setError] = createSignal("");
|
||||
const [copied, setCopied] = createSignal(false);
|
||||
|
||||
const generate = async () => {
|
||||
const project = props.project;
|
||||
if (!project || busy()) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setCopied(false);
|
||||
try {
|
||||
const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, "");
|
||||
const result = props.demoMode
|
||||
? await demoService.createApiKey({ requestBody: { name: `agent-setup-${stamp}` } })
|
||||
: await ProjectsService.createApiKey({
|
||||
projectId: project.id,
|
||||
requestBody: { name: `agent-setup-${stamp}`, scopes: ["write"] },
|
||||
});
|
||||
setKeyPrefix(result.prefix);
|
||||
setPrompt(
|
||||
buildAgentPrompt({
|
||||
baseUrl: window.location.origin,
|
||||
projectId: project.id,
|
||||
projectName: project.name,
|
||||
projectSlug: project.slug,
|
||||
apiKey: result.secret,
|
||||
}),
|
||||
);
|
||||
props.onKeyCreated?.();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e, "Could not create an API key"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Mint a fresh write-scoped key each time the modal opens — secrets are
|
||||
// shown once, so a cached prompt would carry a dead credential. The error
|
||||
// guard prevents a retry loop when minting fails; the user retries manually.
|
||||
createEffect(() => {
|
||||
if (props.open && props.project && props.canManage && !prompt() && !busy() && !error()) {
|
||||
void generate();
|
||||
}
|
||||
if (!props.open) {
|
||||
setPrompt("");
|
||||
setKeyPrefix("");
|
||||
setError("");
|
||||
setCopied(false);
|
||||
}
|
||||
});
|
||||
|
||||
const copyPrompt = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(prompt());
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
/* clipboard unavailable — the text is selectable instead */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={props.open}
|
||||
onClose={props.onClose}
|
||||
title="Set up with an AI agent"
|
||||
description="Paste this prompt into a coding agent (Claude Code, Cursor, Devin…) working in your repository. It instruments monitoring, registers your databases, and connects agent tooling — end to end."
|
||||
size="xl"
|
||||
error={error()}
|
||||
>
|
||||
<Show
|
||||
when={props.canManage}
|
||||
fallback={
|
||||
<p class="text-sm text-text-2">
|
||||
Generating credentials requires the project <strong>admin</strong> role. Ask a project
|
||||
admin to open this dialog and copy the prompt for you.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={prompt()}
|
||||
fallback={
|
||||
<div class="flex items-center gap-3 py-8 justify-center text-sm text-text-2">
|
||||
<Show when={busy()}>
|
||||
<span class="spinner" /> Creating a write-scoped API key…
|
||||
</Show>
|
||||
<Show when={!busy() && error()}>
|
||||
<button class="btn btn-secondary btn-sm" onClick={() => void generate()}>
|
||||
Try again
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="message message-warning mb-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<strong>Fresh key {keyPrefix()}… minted — shown only inside this prompt.</strong>{" "}
|
||||
It grants write access to this project; revoke it under Settings → API keys if the
|
||||
prompt leaks or the agent finishes.
|
||||
</div>
|
||||
</div>
|
||||
<pre
|
||||
class="text-xs rounded-lg border border-border bg-surface-2 p-4 overflow-y-auto"
|
||||
style="max-height: 22rem; white-space: pre-wrap; word-break: break-word"
|
||||
>
|
||||
{prompt()}
|
||||
</pre>
|
||||
<ModalFooter class="px-0 pb-0 pt-4" align="between">
|
||||
<button class="btn btn-ghost btn-sm" onClick={() => void generate()} disabled={busy()}>
|
||||
<IconRefresh class="w-4 h-4" />
|
||||
New key & prompt
|
||||
</button>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-secondary" onClick={props.onClose}>
|
||||
<IconKey class="w-4 h-4" />
|
||||
Done
|
||||
</button>
|
||||
<button class="btn btn-primary" onClick={() => void copyPrompt()}>
|
||||
<Show when={!copied()} fallback={<><IconCheck class="w-4 h-4" /> Copied</>}>
|
||||
<IconCopy class="w-4 h-4" /> Copy prompt
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</Show>
|
||||
</Show>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,8 @@ interface OnboardingModalProps {
|
||||
isOpen: boolean;
|
||||
projectName: string;
|
||||
onClose: () => void;
|
||||
/** fired on the final "Done" step instead of onClose — used to chain into agent setup */
|
||||
onDone?: () => void;
|
||||
}
|
||||
|
||||
const steps = [
|
||||
@@ -88,7 +90,16 @@ export function OnboardingModal(props: OnboardingModalProps) {
|
||||
</Show>
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
onClick={() => (step() < steps.length - 1 ? setStep(step() + 1) : handleClose())}
|
||||
onClick={() => {
|
||||
if (step() < steps.length - 1) {
|
||||
setStep(step() + 1);
|
||||
} else if (props.onDone) {
|
||||
setStep(0);
|
||||
props.onDone();
|
||||
} else {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{step() === steps.length - 1 ? "Done" : "Next"}
|
||||
</button>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
IconCollections,
|
||||
IconIntegrations,
|
||||
IconChevronRight,
|
||||
IconTerminal,
|
||||
} from "./Icons";
|
||||
|
||||
interface ProjectDashboardProps {
|
||||
@@ -15,6 +16,7 @@ interface ProjectDashboardProps {
|
||||
overview?: ProjectOverview;
|
||||
recentAudit?: AuditLog[];
|
||||
onNavigate: (view: string) => void;
|
||||
onAgentSetup?: () => void;
|
||||
}
|
||||
|
||||
function formatBytes(bytes?: number): string {
|
||||
@@ -135,6 +137,12 @@ export function ProjectDashboard(props: ProjectDashboardProps) {
|
||||
</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<Show when={props.onAgentSetup}>
|
||||
<button class="btn btn-secondary btn-sm" onClick={() => props.onAgentSetup!()}>
|
||||
<IconTerminal class="w-3.5 h-3.5" />
|
||||
Agent setup
|
||||
</button>
|
||||
</Show>
|
||||
<button class="btn btn-secondary btn-sm" onClick={() => props.onNavigate("settings")}>
|
||||
<IconKey class="w-3.5 h-3.5" />
|
||||
Keys
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Components barrel export
|
||||
export { AgentSetupModal, type AgentSetupProject } from "./AgentSetupModal";
|
||||
export { AppShell, type ViewType } from "./AppShell";
|
||||
export { Badge, StatusBadge } from "./Badge";
|
||||
export { Button } from "./Button";
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildAgentPrompt } from "../agent-prompt";
|
||||
|
||||
const base = {
|
||||
baseUrl: "https://primora.example.com",
|
||||
projectId: "proj-123",
|
||||
projectName: "Production API",
|
||||
projectSlug: "production-api",
|
||||
apiKey: "prm_abcd_secretkey",
|
||||
};
|
||||
|
||||
describe("buildAgentPrompt", () => {
|
||||
const prompt = buildAgentPrompt(base);
|
||||
|
||||
it("embeds credentials verbatim", () => {
|
||||
expect(prompt).toContain(`PRIMORA_BASE_URL=${base.baseUrl}`);
|
||||
expect(prompt).toContain(`PRIMORA_API_KEY=${base.apiKey}`);
|
||||
expect(prompt).toContain(`PRIMORA_PROJECT=${base.projectId}`);
|
||||
});
|
||||
|
||||
it("points ingest at the deployment's API base", () => {
|
||||
expect(prompt).toContain(`POST ${base.baseUrl}/api/v1/ingest`);
|
||||
expect(prompt).not.toContain(`${base.baseUrl}/api/v1/api/v1`);
|
||||
});
|
||||
|
||||
it("covers monitoring, databases and agent access", () => {
|
||||
expect(prompt).toContain("/db-connections");
|
||||
expect(prompt).toContain("deploy-markers");
|
||||
expect(prompt).toContain("PRIMORA_PROJECT");
|
||||
for (const t of ["postgres", "mysql", "mongodb", "redis"]) {
|
||||
expect(prompt).toContain(t);
|
||||
}
|
||||
});
|
||||
|
||||
it("never fabricates database credentials", () => {
|
||||
expect(prompt).toMatch(/never fabricate credentials/i);
|
||||
});
|
||||
|
||||
it("trims a trailing slash on the base URL", () => {
|
||||
const p = buildAgentPrompt({ ...base, baseUrl: "http://localhost/" });
|
||||
expect(p).toContain("PRIMORA_BASE_URL=http://localhost\n");
|
||||
expect(p).toContain("http://localhost/api/v1/ingest");
|
||||
});
|
||||
|
||||
it("includes a concrete MCP stdio block when a source path is given", () => {
|
||||
const p = buildAgentPrompt({ ...base, primoraSourcePath: "/opt/Primora" });
|
||||
expect(p).toContain("/opt/Primora/apps/mcp/dist/server.js");
|
||||
expect(p).toContain('"mcpServers"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Builds the copy-paste prompt that hands an AI coding agent everything it
|
||||
* needs to wire a repository into a Primora project: telemetry ingest,
|
||||
* database connections, and agent-facing access (MCP/CLI). Pure function so
|
||||
* it can be unit-tested and reused by any surface.
|
||||
*/
|
||||
|
||||
export interface AgentPromptInput {
|
||||
/** Deployment origin, e.g. http://localhost — /api/v1 is appended here. */
|
||||
baseUrl: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
projectSlug: string;
|
||||
/** Full prm_… key shown once at mint time. */
|
||||
apiKey: string;
|
||||
/** Optional absolute path to a local Primora source checkout (enables the MCP build step). */
|
||||
primoraSourcePath?: string;
|
||||
}
|
||||
|
||||
export function buildAgentPrompt(input: AgentPromptInput): string {
|
||||
const base = input.baseUrl.replace(/\/+$/, "");
|
||||
const api = `${base}/api/v1`;
|
||||
const p = input.projectId;
|
||||
|
||||
const mcpSection = input.primoraSourcePath
|
||||
? `A Primora source checkout exists at \`${input.primoraSourcePath}\`. Build the MCP server once:
|
||||
|
||||
\`\`\`bash
|
||||
cd ${input.primoraSourcePath} && npm run build --workspace @primora/mcp # → apps/mcp/dist/server.js
|
||||
\`\`\`
|
||||
|
||||
Then register a stdio MCP server named \`primora\` in your client's MCP config:
|
||||
\`\`\`json
|
||||
{
|
||||
"mcpServers": {
|
||||
"primora": {
|
||||
"command": "node",
|
||||
"args": ["${input.primoraSourcePath}/apps/mcp/dist/server.js"],
|
||||
"env": {
|
||||
"PRIMORA_BASE_URL": "${base}",
|
||||
"PRIMORA_API_KEY": "$PRIMORA_API_KEY",
|
||||
"PRIMORA_PROJECT": "${p}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
This exposes 30+ \`primora_*\` tools (telemetry query, collections, storage, jobs, audit) to every agent that loads the config.`
|
||||
: `If a Primora source checkout is available on this machine, build \`@primora/mcp\` and register it as a stdio MCP server with env \`PRIMORA_BASE_URL=${base}\`, \`PRIMORA_API_KEY\`, \`PRIMORA_PROJECT=${p}\`. Otherwise skip — the REST API above covers the same surface.`;
|
||||
|
||||
return `You are an AI agent. Wire this repository into the Primora project "${input.projectName}" so it reports telemetry, tracks its databases, and is reachable by future agents. Work through every section; do not stop after the first.
|
||||
|
||||
## Credentials
|
||||
|
||||
\`\`\`bash
|
||||
PRIMORA_BASE_URL=${base}
|
||||
PRIMORA_API_KEY=${input.apiKey}
|
||||
PRIMORA_PROJECT=${p}
|
||||
\`\`\`
|
||||
|
||||
- API base: \`${api}\`
|
||||
- Send the key as header \`X-Primora-Key\` (alias \`X-API-Key\` also accepted).
|
||||
- Key scope is \`write\`: it covers telemetry ingest, reads, and non-admin mutations (connections, buckets, collections, webhooks). It cannot mint keys or manage members — that is intentional.
|
||||
- Put these in the project's \`.env\` (add to \`.env.example\` with placeholders) and reference them via env vars in code. Never hardcode or commit the literal key.
|
||||
|
||||
## 1. Monitoring — instrument every component
|
||||
|
||||
All telemetry goes to one endpoint; components self-register on first event:
|
||||
|
||||
\`\`\`
|
||||
POST ${api}/ingest
|
||||
X-Primora-Key: $PRIMORA_API_KEY
|
||||
Content-Type: application/json
|
||||
|
||||
{"events":[{"type":"error","component":"${input.projectSlug}-api","kind":"backend","severity":"error","message":"…","payload":{"stack":"…"},"ts":"<RFC3339>"}]}
|
||||
\`\`\`
|
||||
|
||||
- \`type\`: \`error\` | \`metric\` | \`log\` | \`heartbeat\` | \`event\`. Batches up to 500 events.
|
||||
- \`kind\`: \`frontend\` | \`backend\` | \`database\` | \`web\` | \`android\` | \`desktop\` | \`other\`.
|
||||
- Errors with the same \`message\` (or explicit \`fingerprint\`) group into Issues.
|
||||
|
||||
Detect this repo's stack and instrument each deployable unit — frontend, backend, workers, landing page, all of them:
|
||||
|
||||
- **Browser / React / any web frontend**: add a small telemetry module that batches to /ingest (fetch + \`keepalive: true\`), hooks \`window.onerror\` + \`unhandledrejection\`, and flushes on \`visibilitychange\`. Component kind \`frontend\` or \`web\`.
|
||||
- **Node / TypeScript backends**: hook the framework's error middleware and \`process.on("uncaughtException")\`; send \`heartbeat "up"\` every 30s. Kind \`backend\`.
|
||||
- **Go / Python / Rust / other**: same HTTP contract — wrap panic/recover or global exception handlers and a periodic heartbeat. Any language works; there is no required SDK.
|
||||
- Name components \`${input.projectSlug}-frontend\`, \`${input.projectSlug}-api\`, \`${input.projectSlug}-worker\`, etc.
|
||||
|
||||
Verify now: send one test event and confirm it lands (see §4).
|
||||
|
||||
## 2. Databases — register what this app uses
|
||||
|
||||
Ask the user for the connection details of each database this project uses — never fabricate credentials, never commit them. Register each one so it appears under Primora → Databases (schema browser, query console):
|
||||
|
||||
\`\`\`
|
||||
POST ${api}/projects/${p}/db-connections
|
||||
X-Primora-Key: $PRIMORA_API_KEY
|
||||
|
||||
{"name":"primary","db_type":"postgres","host":"…","port":5432,"database":"…","username":"…","password":"…","ssl":true}
|
||||
\`\`\`
|
||||
|
||||
Supported \`db_type\`: postgres, mysql, sqlite, redis, mongodb, sqlserver, duckdb, clickhouse, cassandra. After creating each connection verify it:
|
||||
|
||||
\`\`\`
|
||||
POST ${api}/projects/${p}/db-connections/{connectionID}/test → {"ok":true|false,"error":"…"}
|
||||
\`\`\`
|
||||
|
||||
Report per-database status to the user. If a database is unreachable, say so — do not silently skip it.
|
||||
|
||||
## 3. Agent access — let future agents manage this project
|
||||
|
||||
${mcpSection}
|
||||
|
||||
Optional CLI path: \`primora login --api-key $PRIMORA_API_KEY\`, then \`primora use\`. For wrapping third-party agents with brokered credentials (the agent never holds real keys), use \`primora agent --<provider> <vault-secret> -- <cmd>\`.
|
||||
|
||||
## 4. Prove it works
|
||||
|
||||
1. \`curl -sS -X POST ${api}/ingest -H "X-Primora-Key: $PRIMORA_API_KEY" -H "Content-Type: application/json" -d '{"events":[{"type":"event","component":"setup","message":"primora.setup.done"}]}'\` — expect \`{"accepted":1}\`.
|
||||
2. Record a deploy marker: \`curl -sS -X POST ${api}/projects/${p}/deploy-markers -H "X-Primora-Key: $PRIMORA_API_KEY" -H "Content-Type: application/json" -d '{"ref":"<git sha>","environment":"dev","note":"agent setup"}'\`.
|
||||
3. Finish with a short summary: components instrumented, database connections tested, and where the data lands (${base} → Telemetry, Databases, Integrations).
|
||||
`;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { ProjectSummary } from "@primora/api-client";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Modal, ModalFooter } from "../components/Modal";
|
||||
import { Input, Textarea } from "../components/Input";
|
||||
import { IconPlus, IconProjects, IconArrowRight, IconEdit, IconTrash } from "../components/Icons";
|
||||
import { IconPlus, IconProjects, IconArrowRight, IconEdit, IconTrash, IconTerminal } from "../components/Icons";
|
||||
|
||||
interface ProjectInput {
|
||||
name: string;
|
||||
@@ -32,6 +32,7 @@ interface ProjectsPageProps {
|
||||
onDeleteProject: () => void;
|
||||
onSelectProject: (id: string) => void;
|
||||
onNavigateToDashboard: () => void;
|
||||
onAgentSetup?: () => void;
|
||||
}
|
||||
|
||||
export function ProjectsPage(props: ProjectsPageProps) {
|
||||
@@ -169,6 +170,12 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Show when={props.onAgentSetup}>
|
||||
<button class="btn btn-secondary" onClick={() => props.onAgentSetup!()}>
|
||||
<IconTerminal class="w-4 h-4" />
|
||||
Agent setup
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
|
||||
Reference in new issue
Block a user