Merge consent-first discovery of projects and connected apps
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
commit
469e6ec5d7
@ -25,9 +25,9 @@ PUBLIC_POSTHOG_KEY=
|
||||
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
||||
SIGNUPS_ENABLED=true
|
||||
SIGNUP_ALLOWLIST=
|
||||
# First-run discovery. Leave unset and the onboarding scan never touches the host filesystem.
|
||||
# Set a home-style directory (its Projects, Documents, src, ... subfolders are scanned) to let the
|
||||
# deployment owner's own onboarding discover local projects. Other accounts never scan the host.
|
||||
# First-run discovery. Leave unset and onboarding never looks at the host filesystem.
|
||||
# Set a folder (for example a home folder) and the deployment owner's onboarding offers to list the
|
||||
# git projects in it, reading file and folder names only. Other accounts never see the step.
|
||||
# ONBOARDING_SCAN_HOME=
|
||||
ENCRYPTION_KEY=replace-with-64-random-hex-characters
|
||||
DATA_DIR=./data
|
||||
|
||||
50
apps/api/src/connected-app-facts.ts
Normal file
50
apps/api/src/connected-app-facts.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { FEATURED_CONNECTOR_LABELS, matchFeaturedConnectorId } from "@rakazo/core";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { updateUserProfile } from "@rakazo/db";
|
||||
import { getLogger } from "@rakazo/logging";
|
||||
|
||||
/**
|
||||
* The app's own name: a featured app's label, otherwise the label of the user's first account for
|
||||
* it (later accounts may be named "Gmail 2" or renamed).
|
||||
*/
|
||||
async function connectedAppName(
|
||||
prisma: PrismaClient,
|
||||
connection: { userId: string; connectorId: string; provider: string },
|
||||
): Promise<string | null> {
|
||||
const featured = matchFeaturedConnectorId(connection.provider);
|
||||
if (featured) return FEATURED_CONNECTOR_LABELS[featured];
|
||||
const first = await prisma.connection.findFirst({
|
||||
where: {
|
||||
userId: connection.userId,
|
||||
connectorId: connection.connectorId,
|
||||
provider: connection.provider,
|
||||
},
|
||||
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||
select: { displayName: true },
|
||||
});
|
||||
const name = first?.displayName.trim().slice(0, 80);
|
||||
return name || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers "Uses <App>" in the user's profile when one of their connections becomes connected.
|
||||
* Revoking the connection keeps the fact; the user removes it in About you. Best effort: the
|
||||
* connection itself has already succeeded, so a full profile or a database error only logs.
|
||||
*/
|
||||
export async function recordConnectedApp(
|
||||
prisma: PrismaClient,
|
||||
connection: { userId: string; connectorId: string; provider: string },
|
||||
): Promise<void> {
|
||||
try {
|
||||
const name = await connectedAppName(prisma, connection);
|
||||
if (!name) return;
|
||||
await updateUserProfile(prisma, connection.userId, {
|
||||
addFacts: [{ category: "tools", value: `Uses ${name}`, source: "connector" }],
|
||||
});
|
||||
} catch (error) {
|
||||
getLogger().error("connected app fact was not recorded", error, {
|
||||
connectorId: connection.connectorId,
|
||||
provider: connection.provider,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,64 +1,192 @@
|
||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js";
|
||||
import path from "node:path";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { afterAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
applyOnboardingScan,
|
||||
discoverOnboardingScan,
|
||||
onboardingScanFolder,
|
||||
onboardingScanStatus,
|
||||
startOnboardingScan,
|
||||
} from "./onboarding-scan.js";
|
||||
|
||||
const owner = { userId: "user-owner", isDeploymentOwner: true };
|
||||
const member = { userId: "user-member", isDeploymentOwner: false };
|
||||
const owner = { userId: "user-owner", spaceId: "space-owner", isDeploymentOwner: true };
|
||||
const member = { userId: "user-member", spaceId: "space-member", isDeploymentOwner: false };
|
||||
const roots: string[] = [];
|
||||
|
||||
async function scanHomeWithOneFile(): Promise<string> {
|
||||
const home = await mkdtemp(join(tmpdir(), "evoo-scan-"));
|
||||
await mkdir(join(home, "Projects"));
|
||||
await writeFile(join(home, "Projects", "notes.ts"), "export const x = 1;\n");
|
||||
return home;
|
||||
/** Fails the test if the scan writes anything before its checks pass. */
|
||||
const untouchedPrisma = new Proxy(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
throw new Error("the database must not be touched");
|
||||
},
|
||||
},
|
||||
) as PrismaClient;
|
||||
|
||||
async function tempDir(): Promise<string> {
|
||||
const dir = await realpath(await mkdtemp(path.join(tmpdir(), "evoo-scan-")));
|
||||
roots.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function files(root: string, entries: string[]): Promise<void> {
|
||||
for (const entry of entries) {
|
||||
const file = path.join(root, entry);
|
||||
await mkdir(path.dirname(file), { recursive: true });
|
||||
await writeFile(file, "x\n");
|
||||
}
|
||||
}
|
||||
|
||||
async function project(root: string, relative: string, entries: string[]): Promise<void> {
|
||||
await mkdir(path.join(root, relative, ".git"), { recursive: true });
|
||||
await files(path.join(root, relative), entries);
|
||||
}
|
||||
|
||||
async function completed(scanId: string, userId: string) {
|
||||
return await vi.waitFor(
|
||||
async () => {
|
||||
const status = await getScanStatus(scanId, userId);
|
||||
if (status.status !== "complete" && status.status !== "failed") throw new Error("pending");
|
||||
() => {
|
||||
const status = onboardingScanStatus(scanId, userId);
|
||||
if (status.status === "scanning") throw new Error("pending");
|
||||
return status;
|
||||
},
|
||||
{ timeout: 10_000, interval: 50 },
|
||||
{ timeout: 10_000, interval: 20 },
|
||||
);
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("onboarding scan", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
it("lists git projects with their languages and tools and never follows a symlink", async () => {
|
||||
const home = await tempDir();
|
||||
const outside = await tempDir();
|
||||
// A home folder tracked by git is not itself a project.
|
||||
await mkdir(path.join(home, ".git"));
|
||||
await project(home, "code/atlas", [
|
||||
"src/a.ts",
|
||||
"src/b.ts",
|
||||
"src/c.ts",
|
||||
"src/d.tsx",
|
||||
"scripts/build.js",
|
||||
"Dockerfile",
|
||||
".github/workflows/ci.yml",
|
||||
"infra/main.tf",
|
||||
"src/odd.constructor",
|
||||
"node_modules/dep/index.js",
|
||||
"node_modules/dep/more.js",
|
||||
]);
|
||||
await project(home, "code/garden", ["main.py"]);
|
||||
await project(home, "deep/a/b/edge", ["tool.py"]);
|
||||
await project(home, "deep/a/b/c/too-deep", ["main.go"]);
|
||||
await project(home, ".hidden/secret", ["main.go"]);
|
||||
await project(home, "notes/node_modules/vendored", ["main.go"]);
|
||||
await files(home, ["notes/todo.md"]);
|
||||
await project(outside, "escaped", ["main.rs"]);
|
||||
await symlink(path.join(outside, "escaped"), path.join(home, "linked"));
|
||||
await symlink(outside, path.join(home, "code/atlas/src/outside"));
|
||||
|
||||
const findings = await discoverOnboardingScan(home);
|
||||
|
||||
expect(findings.projects).toEqual([
|
||||
{
|
||||
id: "project-0",
|
||||
name: "atlas",
|
||||
path: "code/atlas",
|
||||
languages: ["TypeScript", "JavaScript"],
|
||||
tools: ["Docker", "GitHub Actions", "Terraform"],
|
||||
},
|
||||
{ id: "project-1", name: "garden", path: "code/garden", languages: ["Python"], tools: [] },
|
||||
{ id: "project-2", name: "edge", path: "deep/a/b/edge", languages: ["Python"], tools: [] },
|
||||
]);
|
||||
expect(findings.facts).toEqual([
|
||||
{ id: "language:Python", label: "Python", category: "work", value: "Codes in Python" },
|
||||
{
|
||||
id: "language:JavaScript",
|
||||
label: "JavaScript",
|
||||
category: "work",
|
||||
value: "Codes in JavaScript",
|
||||
},
|
||||
{
|
||||
id: "language:TypeScript",
|
||||
label: "TypeScript",
|
||||
category: "work",
|
||||
value: "Codes in TypeScript",
|
||||
},
|
||||
{ id: "tool:Docker", label: "Docker", category: "tools", value: "Uses Docker" },
|
||||
{
|
||||
id: "tool:GitHub Actions",
|
||||
label: "GitHub Actions",
|
||||
category: "tools",
|
||||
value: "Uses GitHub Actions",
|
||||
},
|
||||
{ id: "tool:Terraform", label: "Terraform", category: "tools", value: "Uses Terraform" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("hides a scan from every account except the one that started it", async () => {
|
||||
const { scanId } = await startOnboardingScan({ actor: member }, {});
|
||||
await expect(getScanStatus(scanId, owner.userId)).rejects.toThrow(/Scan not found/);
|
||||
await expect(getScanStatus(scanId, member.userId)).resolves.toMatchObject({
|
||||
status: expect.any(String),
|
||||
});
|
||||
await expect(getScanStatus("scan_missing", member.userId)).rejects.toThrow(/Scan not found/);
|
||||
it("offers a scan folder only to the deployment owner and only when it exists", async () => {
|
||||
const home = await tempDir();
|
||||
await files(home, ["file.txt"]);
|
||||
await expect(onboardingScanFolder(member, home)).resolves.toBeNull();
|
||||
await expect(onboardingScanFolder(owner, undefined)).resolves.toBeNull();
|
||||
await expect(onboardingScanFolder(owner, path.join(home, "missing"))).resolves.toBeNull();
|
||||
await expect(onboardingScanFolder(owner, path.join(home, "file.txt"))).resolves.toBeNull();
|
||||
await expect(onboardingScanFolder(owner, home)).resolves.toBe(home);
|
||||
await expect(startOnboardingScan(member, home)).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
|
||||
it("never reads the host without a configured scan home", async () => {
|
||||
const { scanId } = await startOnboardingScan({ actor: owner }, {});
|
||||
it("keeps a scan with the account that started it and returns only relative paths", async () => {
|
||||
const home = await tempDir();
|
||||
await project(home, "code/atlas", ["index.ts"]);
|
||||
const { scanId } = await startOnboardingScan(owner, home);
|
||||
|
||||
const status = await completed(scanId, owner.userId);
|
||||
expect(status.status).toBe("complete");
|
||||
expect(status.result).toMatchObject({ localRepos: [], fileSummary: { totalFiles: 0 } });
|
||||
expect(status).toEqual({
|
||||
status: "complete",
|
||||
projects: [{ id: "project-0", name: "atlas", path: "code/atlas" }],
|
||||
facts: [{ id: "language:TypeScript", label: "TypeScript" }],
|
||||
});
|
||||
expect(JSON.stringify(status)).not.toContain(home);
|
||||
expect(() => onboardingScanStatus(scanId, member.userId)).toThrow(
|
||||
expect.objectContaining({ code: "NOT_FOUND" }),
|
||||
);
|
||||
expect(() => onboardingScanStatus("missing-scan", owner.userId)).toThrow(
|
||||
expect.objectContaining({ code: "NOT_FOUND" }),
|
||||
);
|
||||
await expect(
|
||||
applyOnboardingScan(untouchedPrisma, member, home, {
|
||||
scanId,
|
||||
projectIds: ["project-0"],
|
||||
factIds: [],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("lets only the deployment owner discover the configured scan home", async () => {
|
||||
// The shared test setup pins the emulated runtime, which also keeps the scan simulated.
|
||||
vi.stubEnv("AGENT_RUNTIME", "");
|
||||
vi.stubEnv("SANDBOX_PROVIDER", "");
|
||||
const scanHome = await scanHomeWithOneFile();
|
||||
it("checks ownership, the folder and every chosen project again before writing", async () => {
|
||||
const home = await tempDir();
|
||||
const outside = await tempDir();
|
||||
await project(home, "code/atlas", ["index.ts"]);
|
||||
await project(home, "code/garden", ["main.py"]);
|
||||
await project(outside, "elsewhere", ["main.rs"]);
|
||||
const { scanId } = await startOnboardingScan(owner, home);
|
||||
await completed(scanId, owner.userId);
|
||||
const choice = { scanId, projectIds: ["project-1"], factIds: ["language:Python"] };
|
||||
|
||||
const asMember = await startOnboardingScan({ actor: member, scanHome }, {});
|
||||
const memberStatus = await completed(asMember.scanId, member.userId);
|
||||
expect(memberStatus.result).toMatchObject({ fileSummary: { totalFiles: 0 } });
|
||||
await expect(
|
||||
applyOnboardingScan(untouchedPrisma, { ...owner, isDeploymentOwner: false }, home, choice),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
await expect(
|
||||
applyOnboardingScan(untouchedPrisma, owner, outside, choice),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
|
||||
const asOwner = await startOnboardingScan({ actor: owner, scanHome }, {});
|
||||
const ownerStatus = await completed(asOwner.scanId, owner.userId);
|
||||
expect(ownerStatus.status).toBe("complete");
|
||||
expect(ownerStatus.result).toMatchObject({ fileSummary: { totalFiles: 1 } });
|
||||
// The chosen project became a symlink out of the scan folder after the scan.
|
||||
await rm(path.join(home, "code/garden"), { recursive: true });
|
||||
await symlink(path.join(outside, "elsewhere"), path.join(home, "code/garden"));
|
||||
await expect(applyOnboardingScan(untouchedPrisma, owner, home, choice)).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
message: "That folder does not exist or is not readable.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -164,6 +164,7 @@ import {
|
||||
resolveBusyBotName,
|
||||
toComputerStatus,
|
||||
} from "./computer-status.js";
|
||||
import { recordConnectedApp } from "./connected-app-facts.js";
|
||||
import { listInbox } from "./inbox.js";
|
||||
import { searchIntegrationCatalog } from "./integration-catalog.js";
|
||||
import {
|
||||
@ -187,7 +188,12 @@ import {
|
||||
promptFocus,
|
||||
startOnboarding,
|
||||
} from "./onboarding.js";
|
||||
import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js";
|
||||
import {
|
||||
applyOnboardingScan,
|
||||
onboardingScanFolder,
|
||||
onboardingScanStatus,
|
||||
startOnboardingScan,
|
||||
} from "./onboarding-scan.js";
|
||||
import { listSpaceRuns } from "./runs.js";
|
||||
import { addScreenProxyCapability } from "./screen-proxy.js";
|
||||
import { querySpaceSearch } from "./search.js";
|
||||
@ -292,6 +298,11 @@ async function reconcilePendingConnections(
|
||||
: []),
|
||||
];
|
||||
if (updates.length > 0) await prisma.$transaction(updates);
|
||||
const connected = new Set(sync.connectIds);
|
||||
for (const row of rows) {
|
||||
if (row.status !== "pending" || !connected.has(row.id)) continue;
|
||||
await recordConnectedApp(prisma, { userId: owner.userId, connectorId, provider: row.provider });
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialize begin/revoke for one user+provider so slug-wide remote deletes cannot race a new connect. */
|
||||
@ -3454,16 +3465,18 @@ export function createRouter(deps: RouterDeps) {
|
||||
);
|
||||
return { ok: true as const };
|
||||
}),
|
||||
scan: authed.onboarding.scan.handler(async ({ context, input }) => {
|
||||
const { scanId } = await startOnboardingScan(
|
||||
{ actor: context.actor, botId: input.botId, scanHome: deps.env.onboardingScanHome },
|
||||
deps.prisma,
|
||||
);
|
||||
return { scanId };
|
||||
}),
|
||||
scanStatus: authed.onboarding.scanStatus.handler(async ({ context, input }) => {
|
||||
return await getScanStatus(input.scanId, context.actor.userId);
|
||||
}),
|
||||
scanTarget: authed.onboarding.scanTarget.handler(async ({ context }) => ({
|
||||
folder: await onboardingScanFolder(context.actor, deps.env.onboardingScanHome),
|
||||
})),
|
||||
scan: authed.onboarding.scan.handler(({ context }) =>
|
||||
startOnboardingScan(context.actor, deps.env.onboardingScanHome),
|
||||
),
|
||||
scanStatus: authed.onboarding.scanStatus.handler(async ({ context, input }) =>
|
||||
onboardingScanStatus(input.scanId, context.actor.userId),
|
||||
),
|
||||
applyScan: authed.onboarding.applyScan.handler(({ context, input }) =>
|
||||
applyOnboardingScan(deps.prisma, context.actor, deps.env.onboardingScanHome, input),
|
||||
),
|
||||
},
|
||||
integrationSetup: {
|
||||
get: authed.integrationSetup.get.handler(async ({ context }) => {
|
||||
@ -3712,6 +3725,13 @@ export function createRouter(deps: RouterDeps) {
|
||||
}
|
||||
throw new IsolationError();
|
||||
}
|
||||
if (!auth.authorizationUrl) {
|
||||
await recordConnectedApp(deps.prisma, {
|
||||
userId: context.actor.userId,
|
||||
connectorId: input.connectorId,
|
||||
provider: input.provider,
|
||||
});
|
||||
}
|
||||
return { connectionId: row.id, authorizationUrl: auth.authorizationUrl };
|
||||
} catch (error) {
|
||||
if (error instanceof IsolationError) throw error;
|
||||
@ -4017,6 +4037,13 @@ export function createRouter(deps: RouterDeps) {
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
if (row.status === "connected") {
|
||||
await recordConnectedApp(deps.prisma, {
|
||||
userId: context.actor.userId,
|
||||
connectorId: row.connectorId,
|
||||
provider: row.provider,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
|
||||
76
apps/web/e2e/onboarding-discovery.spec.ts
Normal file
76
apps/web/e2e/onboarding-discovery.spec.ts
Normal file
@ -0,0 +1,76 @@
|
||||
/**
|
||||
* E2E: the deployment owner's onboarding asks before scanning the fixture home folder the
|
||||
* harness configures, then adds only the projects and facts the user picks.
|
||||
*/
|
||||
import { expect, type Page, test } from "@playwright/test";
|
||||
import type { SpaceNavigation, UserProfileState } from "@rakazo/contracts";
|
||||
import { captureScreenshot, rpc, signup } from "./helpers";
|
||||
|
||||
const api = process.env.API_URL ?? "http://127.0.0.1:3110";
|
||||
|
||||
/** Advances whichever wizard step is showing until the scan step is reached. */
|
||||
async function walkToScanStep(page: Page) {
|
||||
const surface = page.locator('[data-rakazo-surface="onboarding"]');
|
||||
await expect(async () => {
|
||||
if ((await surface.getAttribute("data-onboarding-step")) === "scan") return;
|
||||
const skip = page.getByRole("button", { name: /^Skip/ }).first();
|
||||
const next = (await skip.isVisible())
|
||||
? skip
|
||||
: page.getByRole("button", { name: "Continue", exact: true });
|
||||
await next.click({ timeout: 2_000 });
|
||||
await expect(surface).toHaveAttribute("data-onboarding-step", "scan", { timeout: 1_000 });
|
||||
}).toPass({ timeout: 60_000 });
|
||||
}
|
||||
|
||||
test("asks before looking for projects and adds only what the user picks", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const email = `discovery-${Date.now()}@rakazo.test`;
|
||||
await signup(page, email, "password12", "Discovery");
|
||||
await page.waitForURL(/\/onboarding/, { timeout: 20_000 });
|
||||
// The harness starts without a deployment owner; this spec takes the seat for its run.
|
||||
const owner = await page.request.post(`${api}/__e2e/deployment-owner`, { data: { email } });
|
||||
expect(owner.ok()).toBe(true);
|
||||
const { previousOwnerUserId } = (await owner.json()) as { previousOwnerUserId: string | null };
|
||||
try {
|
||||
await page.goto("/onboarding", { waitUntil: "domcontentloaded" });
|
||||
await walkToScanStep(page);
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Look for projects here?" })).toBeVisible();
|
||||
await expect(page.getByText(/rakazo-e2e-scan-home-/)).toBeVisible();
|
||||
await captureScreenshot(page, testInfo, "onboarding-discovery-consent");
|
||||
await page.getByRole("button", { name: "Look", exact: true }).click();
|
||||
|
||||
const projects = page.getByRole("group", { name: "Add as projects" });
|
||||
await expect(projects.getByRole("checkbox")).toHaveCount(2);
|
||||
const atlas = projects.getByRole("checkbox", { name: /atlas/ });
|
||||
await expect(atlas).not.toBeChecked();
|
||||
await expect(projects.getByRole("checkbox", { name: /garden/ })).not.toBeChecked();
|
||||
await atlas.check();
|
||||
const facts = page.getByRole("group", { name: "Add to About you" });
|
||||
await expect(facts.getByRole("checkbox", { name: "TypeScript" })).toBeChecked();
|
||||
await facts.getByRole("checkbox", { name: "Python" }).uncheck();
|
||||
await captureScreenshot(page, testInfo, "onboarding-discovery-results");
|
||||
await page.getByRole("button", { name: "Add", exact: true }).click();
|
||||
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
|
||||
|
||||
const navigation = await rpc<SpaceNavigation>(page, "spaces/list", {});
|
||||
const names = navigation.spaces.map((space) => space.name);
|
||||
expect(names).toContain("atlas");
|
||||
expect(names).not.toContain("garden");
|
||||
expect(navigation.spaces.find((space) => space.name === "atlas")?.workspace?.path).toMatch(
|
||||
/code\/atlas$/,
|
||||
);
|
||||
const values = (await rpc<UserProfileState>(page, "profile/get", {})).facts.map(
|
||||
(fact) => fact.value,
|
||||
);
|
||||
expect(values).toEqual(
|
||||
expect.arrayContaining(["Codes in TypeScript", "Uses Docker", "Uses GitHub Actions"]),
|
||||
);
|
||||
expect(values).not.toContain("Codes in Python");
|
||||
} finally {
|
||||
await page.request.post(`${api}/__e2e/deployment-owner`, {
|
||||
data: { userId: previousOwnerUserId },
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -1,186 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { rpc } from "../../lib/rpc";
|
||||
|
||||
interface ScanStatus {
|
||||
status: "scanning" | "connecting" | "building" | "briefing" | "complete" | "failed";
|
||||
progress: number;
|
||||
message: string;
|
||||
result?: unknown;
|
||||
}
|
||||
|
||||
const PHASES = [
|
||||
{
|
||||
key: "scanning" as const,
|
||||
label: "Scanning your computer...",
|
||||
sublabel: "Files, folders, git repositories",
|
||||
},
|
||||
{
|
||||
key: "connecting" as const,
|
||||
label: "Connecting to your services...",
|
||||
sublabel: "GitHub, email, calendar",
|
||||
},
|
||||
{
|
||||
key: "building" as const,
|
||||
label: "Building your profile...",
|
||||
sublabel: "Interests, skills, preferences",
|
||||
},
|
||||
{
|
||||
key: "briefing" as const,
|
||||
label: "Setting up your agent team...",
|
||||
sublabel: "Project folders, role assignments",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function OnboardingScanProgress({
|
||||
onComplete,
|
||||
botId,
|
||||
}: {
|
||||
onComplete: () => void;
|
||||
botId?: string;
|
||||
}) {
|
||||
const [status, setStatus] = useState<ScanStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [, setScanId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let pollingInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
const scanResult = await rpc.onboarding.scan(botId ? { botId } : {});
|
||||
if (cancelled) return;
|
||||
setScanId(scanResult.scanId);
|
||||
|
||||
// Poll for status
|
||||
const poll = async () => {
|
||||
try {
|
||||
const s = await rpc.onboarding.scanStatus({ scanId: scanResult.scanId });
|
||||
if (cancelled) return;
|
||||
setStatus(s);
|
||||
if (s.status === "complete") {
|
||||
onComplete();
|
||||
} else if (s.status === "failed") {
|
||||
setError(s.message);
|
||||
}
|
||||
} catch {
|
||||
// poll error — keep trying
|
||||
}
|
||||
};
|
||||
|
||||
// First poll immediately, then every 2s
|
||||
await poll();
|
||||
pollingInterval = setInterval(poll, 2000);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : "Scan failed");
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (pollingInterval) clearInterval(pollingInterval);
|
||||
};
|
||||
}, [botId, onComplete]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl text-center">
|
||||
<p className="text-destructive">{error}</p>
|
||||
<button type="button" className="mt-4 rounded-lg border px-4 py-2" onClick={onComplete}>
|
||||
Continue anyway
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl text-center">
|
||||
<div className="mt-6 animate-spin rounded-full border-4 border-border border-t-primary h-8 w-8" />
|
||||
<p className="mt-4 text-muted-foreground">Preparing your profile...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentPhaseIndex = PHASES.findIndex((p) => p.key === status.status);
|
||||
const isComplete = status.status === "complete";
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<h2 className="text-xl font-semibold tracking-tight text-foreground text-center">
|
||||
{isComplete ? "All set!" : "Getting to know you..."}
|
||||
</h2>
|
||||
<p className="mt-1 text-center text-sm text-muted-foreground">
|
||||
{isComplete
|
||||
? "Your profile and agent team are ready."
|
||||
: "We're scanning your computer and services to personalize your experience."}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
{PHASES.map((phase, idx) => {
|
||||
const isActive = idx === currentPhaseIndex && !isComplete;
|
||||
const isDone = idx < currentPhaseIndex || isComplete;
|
||||
return (
|
||||
<div
|
||||
key={phase.key}
|
||||
className={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-all ${
|
||||
isActive
|
||||
? "border-primary bg-primary/5"
|
||||
: isDone
|
||||
? "border-emerald-500/30 bg-emerald-500/5"
|
||||
: "border-border bg-card"
|
||||
}`}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{isDone ? (
|
||||
<span className="grid size-6 items-center justify-center rounded-full bg-emerald-500/20 text-emerald-500 text-sm">
|
||||
✓
|
||||
</span>
|
||||
) : isActive ? (
|
||||
<span className="grid size-6 items-center justify-center rounded-full bg-primary/20 animate-pulse text-primary text-sm">
|
||||
●
|
||||
</span>
|
||||
) : (
|
||||
<span className="grid size-6 items-center justify-center rounded-full bg-muted/30 text-muted-foreground text-sm">
|
||||
○
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={`text-sm font-medium ${isActive ? "text-foreground" : "text-muted-foreground"}`}
|
||||
>
|
||||
{phase.label}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/60">{phase.sublabel}</p>
|
||||
</div>
|
||||
{isActive && (
|
||||
<div className="flex-shrink-0">
|
||||
<div className="h-1 w-16 overflow-hidden rounded-full bg-muted/30">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-500"
|
||||
style={{ width: `${status.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{isComplete && (
|
||||
<div className="mt-8 text-center">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-xl bg-primary px-6 py-3 text-sm font-medium text-white"
|
||||
onClick={onComplete}
|
||||
>
|
||||
Continue to your workspace →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
250
apps/web/src/components/onboarding/OnboardingScanStep.tsx
Normal file
250
apps/web/src/components/onboarding/OnboardingScanStep.tsx
Normal file
@ -0,0 +1,250 @@
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import type { OnboardingScanFact, OnboardingScanProject } from "@rakazo/contracts";
|
||||
import { Button, Checkbox } from "@rakazo/ui-web";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { rpc } from "../../lib/rpc";
|
||||
import { LoadingState } from "../ai/primitives";
|
||||
|
||||
const POLL_MS = 1_000;
|
||||
|
||||
interface Found {
|
||||
projects: OnboardingScanProject[];
|
||||
facts: OnboardingScanFact[];
|
||||
}
|
||||
|
||||
function toggled(ids: string[], id: string, on: boolean): string[] {
|
||||
return on ? [...ids, id] : ids.filter((entry) => entry !== id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks before looking at the server's scan folder, then lets the user choose which found projects
|
||||
* become project spaces and which languages and tools go to About you. Nothing is created without
|
||||
* a choice. Skips itself when this account cannot scan.
|
||||
*/
|
||||
export function OnboardingScanStep({ onDone }: { onDone: () => void }) {
|
||||
const { t } = useLingui();
|
||||
const ids = useId();
|
||||
const done = useRef(onDone);
|
||||
done.current = onDone;
|
||||
const [folder, setFolder] = useState<string | null>(null);
|
||||
const [looking, setLooking] = useState(false);
|
||||
const [scanId, setScanId] = useState<string | null>(null);
|
||||
const [found, setFound] = useState<Found | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [projectIds, setProjectIds] = useState<string[]>([]);
|
||||
const [factIds, setFactIds] = useState<string[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
rpc.onboarding
|
||||
.scanTarget()
|
||||
.then((target) => {
|
||||
if (cancelled) return;
|
||||
if (target.folder) setFolder(target.folder);
|
||||
else done.current();
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) done.current();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scanId) return;
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const poll = () => {
|
||||
rpc.onboarding
|
||||
.scanStatus({ scanId })
|
||||
.then((status) => {
|
||||
if (cancelled) return;
|
||||
if (status.status === "scanning") {
|
||||
timer = setTimeout(poll, POLL_MS);
|
||||
} else if (status.status === "failed") {
|
||||
setFailed(true);
|
||||
} else {
|
||||
setFound({ projects: status.projects, facts: status.facts });
|
||||
setFactIds(status.facts.map((fact) => fact.id));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setFailed(true);
|
||||
});
|
||||
};
|
||||
poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [scanId]);
|
||||
|
||||
async function look() {
|
||||
setLooking(true);
|
||||
try {
|
||||
setScanId((await rpc.onboarding.scan()).scanId);
|
||||
} catch {
|
||||
setFailed(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function add() {
|
||||
if (!scanId) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await rpc.onboarding.applyScan({ scanId, projectIds, factIds });
|
||||
done.current();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const continueButton = (
|
||||
<div className="mt-8 flex justify-end">
|
||||
<Button onClick={() => done.current()}>
|
||||
<Trans>Continue</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<p className="text-sm text-destructive">
|
||||
<Trans>Could not look in this folder.</Trans>
|
||||
</p>
|
||||
{continueButton}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (found && found.projects.length === 0) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<h2 className="text-xl font-semibold tracking-tight text-foreground">
|
||||
<Trans>No projects found</Trans>
|
||||
</h2>
|
||||
{continueButton}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (found) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<fieldset className="border-0 p-0">
|
||||
<legend>
|
||||
<h2 className="text-xl font-semibold tracking-tight text-foreground">
|
||||
<Trans>Add as projects</Trans>
|
||||
</h2>
|
||||
</legend>
|
||||
<div className="mt-3 max-h-72 space-y-1 overflow-y-auto">
|
||||
{found.projects.map((project) => (
|
||||
<label
|
||||
key={project.id}
|
||||
htmlFor={`${ids}-${project.id}`}
|
||||
className="flex cursor-pointer items-start gap-3 rounded-lg px-2 py-2 hover:bg-muted"
|
||||
>
|
||||
<Checkbox
|
||||
id={`${ids}-${project.id}`}
|
||||
checked={projectIds.includes(project.id)}
|
||||
disabled={saving}
|
||||
onCheckedChange={(checked) =>
|
||||
setProjectIds((current) => toggled(current, project.id, checked))
|
||||
}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-foreground">{project.name}</span>
|
||||
{project.path !== project.name ? (
|
||||
<span className="block truncate font-mono text-xs text-muted-foreground">
|
||||
{project.path}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{found.facts.length > 0 ? (
|
||||
<fieldset className="mt-6 border-0 p-0">
|
||||
<legend className="text-sm font-medium text-foreground">
|
||||
<Trans>Add to About you</Trans>
|
||||
</legend>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{found.facts.map((fact) => (
|
||||
<label
|
||||
key={fact.id}
|
||||
htmlFor={`${ids}-${fact.id}`}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-full border border-border px-3 py-1.5 text-sm text-foreground hover:bg-muted"
|
||||
>
|
||||
<Checkbox
|
||||
id={`${ids}-${fact.id}`}
|
||||
checked={factIds.includes(fact.id)}
|
||||
disabled={saving}
|
||||
onCheckedChange={(checked) =>
|
||||
setFactIds((current) => toggled(current, fact.id, checked))
|
||||
}
|
||||
/>
|
||||
{fact.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
) : null}
|
||||
|
||||
{error ? <p className="mt-4 text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" disabled={saving} onClick={() => done.current()}>
|
||||
<Trans>Skip</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
disabled={saving || (projectIds.length === 0 && factIds.length === 0)}
|
||||
onClick={() => void add()}
|
||||
>
|
||||
<Trans>Add</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (looking) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<LoadingState label={t`Looking`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!folder) return null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<h2 className="text-xl font-semibold tracking-tight text-foreground">
|
||||
<Trans>Look for projects here?</Trans>
|
||||
</h2>
|
||||
<p className="mt-3 break-all rounded-lg bg-muted px-3 py-2 font-mono text-sm text-foreground">
|
||||
{folder}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
<Trans>Only file and folder names are read.</Trans>
|
||||
</p>
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" onClick={() => done.current()}>
|
||||
<Trans>Skip</Trans>
|
||||
</Button>
|
||||
<Button onClick={() => void look()}>
|
||||
<Trans>Look</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -9,7 +9,7 @@ import {
|
||||
type OnboardingEmojiConfig,
|
||||
OnboardingOoDesigner,
|
||||
} from "../components/onboarding/OnboardingOoDesigner";
|
||||
import { OnboardingScanProgress } from "../components/onboarding/OnboardingScanProgress";
|
||||
import { OnboardingScanStep } from "../components/onboarding/OnboardingScanStep";
|
||||
import {
|
||||
KOKORO_LOCAL_TOKEN,
|
||||
OnboardingVoiceStep,
|
||||
@ -115,7 +115,6 @@ export function OnboardingPage() {
|
||||
const [profilePersisted, setProfilePersisted] = useState(false);
|
||||
const [crewReady, setCrewReady] = useState(false);
|
||||
const [_helpVibe, setHelpVibe] = useState<UserHelpVibeHints | null>(null);
|
||||
const [scanBotId, setScanBotId] = useState<string | null>(null);
|
||||
|
||||
const [emojiConfig, setEmojiConfig] = useState<OnboardingEmojiConfig>(defaultEmojiConfig);
|
||||
const [prefs, setPrefs] = useState<UserPreferences>(defaultPreferences());
|
||||
@ -141,14 +140,6 @@ export function OnboardingPage() {
|
||||
if (cancelled) return;
|
||||
setBootstrapMe(me);
|
||||
setHelpVibe(inferHelpVibeHints(collectLocalProfileSignals(me)));
|
||||
// Get bot ID for scan phase
|
||||
try {
|
||||
const botList = await rpc.bots.list();
|
||||
const mainBot = botList.find((b) => b.kind === "main");
|
||||
if (mainBot && !cancelled) setScanBotId(mainBot.id);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
setIntegrationSetup(integrations);
|
||||
// The first step renders before this resolves; never pull a user back who moved on.
|
||||
if (!me.needsModel && integrations?.needsSetup) {
|
||||
@ -513,12 +504,7 @@ export function OnboardingPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === "scan" ? (
|
||||
<OnboardingScanProgress
|
||||
onComplete={() => setStep("bot")}
|
||||
botId={scanBotId ?? undefined}
|
||||
/>
|
||||
) : null}
|
||||
{step === "scan" ? <OnboardingScanStep onDone={() => setStep("bot")} /> : null}
|
||||
|
||||
{step === "bot" && error ? (
|
||||
<div className="text-center">
|
||||
|
||||
@ -13,6 +13,7 @@ export * from "./local-settings.js";
|
||||
export * from "./mcp.js";
|
||||
export * from "./model-tiers.js";
|
||||
export * from "./notifications.js";
|
||||
export * from "./onboarding-scan.js";
|
||||
export * from "./openai-compatible-ui.js";
|
||||
export * from "./project-goals.js";
|
||||
export * from "./project-sessions.js";
|
||||
|
||||
49
packages/contracts/src/onboarding-scan.ts
Normal file
49
packages/contracts/src/onboarding-scan.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import * as z from "zod";
|
||||
import { Id } from "./ids.js";
|
||||
|
||||
/** Upper bounds on one scan's choices; the server lists at most this many projects and facts. */
|
||||
export const ONBOARDING_SCAN_MAX_PROJECTS = 30;
|
||||
export const ONBOARDING_SCAN_MAX_FACTS = 20;
|
||||
|
||||
/** The folder a consented scan would read, or null when this account cannot scan. */
|
||||
export const OnboardingScanTargetSchema = z.object({ folder: z.string().nullable() });
|
||||
export type OnboardingScanTarget = z.infer<typeof OnboardingScanTargetSchema>;
|
||||
|
||||
export const OnboardingScanProjectSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
/** Relative to the scanned folder. */
|
||||
path: z.string(),
|
||||
});
|
||||
export type OnboardingScanProject = z.infer<typeof OnboardingScanProjectSchema>;
|
||||
|
||||
export const OnboardingScanFactSchema = z.object({
|
||||
id: z.string(),
|
||||
/** A language or tool name, e.g. "TypeScript". */
|
||||
label: z.string(),
|
||||
});
|
||||
export type OnboardingScanFact = z.infer<typeof OnboardingScanFactSchema>;
|
||||
|
||||
export const OnboardingScanStatusSchema = z.discriminatedUnion("status", [
|
||||
z.object({ status: z.literal("scanning") }),
|
||||
z.object({ status: z.literal("failed") }),
|
||||
z.object({
|
||||
status: z.literal("complete"),
|
||||
projects: z.array(OnboardingScanProjectSchema),
|
||||
facts: z.array(OnboardingScanFactSchema),
|
||||
}),
|
||||
]);
|
||||
export type OnboardingScanStatus = z.infer<typeof OnboardingScanStatusSchema>;
|
||||
|
||||
export const OnboardingScanApplyInputSchema = z.object({
|
||||
scanId: z.string(),
|
||||
projectIds: z.array(z.string()).max(ONBOARDING_SCAN_MAX_PROJECTS),
|
||||
factIds: z.array(z.string()).max(ONBOARDING_SCAN_MAX_FACTS),
|
||||
});
|
||||
export type OnboardingScanApplyInput = z.infer<typeof OnboardingScanApplyInputSchema>;
|
||||
|
||||
export const OnboardingScanApplyResultSchema = z.object({
|
||||
spaces: z.array(z.object({ id: Id, name: z.string() })),
|
||||
factsAdded: z.number().int().nonnegative(),
|
||||
});
|
||||
export type OnboardingScanApplyResult = z.infer<typeof OnboardingScanApplyResultSchema>;
|
||||
@ -90,6 +90,12 @@ import {
|
||||
WebPushEndpointSchema,
|
||||
WebPushSubscriptionInput,
|
||||
} from "./notifications.js";
|
||||
import {
|
||||
OnboardingScanApplyInputSchema,
|
||||
OnboardingScanApplyResultSchema,
|
||||
OnboardingScanStatusSchema,
|
||||
OnboardingScanTargetSchema,
|
||||
} from "./onboarding-scan.js";
|
||||
import {
|
||||
CreateProjectGoalInput,
|
||||
ProjectGoalSchema,
|
||||
@ -683,15 +689,13 @@ export const appContract = {
|
||||
z.object({ botId: Id, provider: z.string(), connectorId: z.string().default("composio") }),
|
||||
)
|
||||
.output(z.object({ ok: z.literal(true) })),
|
||||
scan: oc.input(z.object({ botId: Id.optional() })).output(z.object({ scanId: z.string() })),
|
||||
scanStatus: oc.input(z.object({ scanId: z.string() })).output(
|
||||
z.object({
|
||||
status: z.enum(["scanning", "connecting", "building", "briefing", "complete", "failed"]),
|
||||
progress: z.number(),
|
||||
message: z.string(),
|
||||
result: z.any().optional(),
|
||||
}),
|
||||
),
|
||||
/** The folder a scan would read; null when this account cannot scan, so the step is skipped. */
|
||||
scanTarget: oc.output(OnboardingScanTargetSchema),
|
||||
/** Start scanning that folder. Only called after the user agreed. */
|
||||
scan: oc.output(z.object({ scanId: z.string() })),
|
||||
scanStatus: oc.input(z.object({ scanId: z.string() })).output(OnboardingScanStatusSchema),
|
||||
/** Create a project space per chosen project and save the chosen facts. */
|
||||
applyScan: oc.input(OnboardingScanApplyInputSchema).output(OnboardingScanApplyResultSchema),
|
||||
},
|
||||
integrationSetup: {
|
||||
get: oc.output(IntegrationSetupStateSchema),
|
||||
|
||||
@ -1,19 +1,10 @@
|
||||
// Server only: reached through the "@rakazo/core/git-checkout" subpath, never the browser entry.
|
||||
import { spawn } from "node:child_process";
|
||||
import { access } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { gitStatusArgv, gitUntrackedFilesArgv, parseGitNameOnly } from "./compose-update.js";
|
||||
import { DEFAULT_UPDATE_REMOTE, parseGitStatusPorcelain } from "./self-update.js";
|
||||
|
||||
// Lazy import to avoid bundling `node:child_process` in browser bundles.
|
||||
// `defaultGitRunner` is server-only; lazy-loading ensures tree-shaking works.
|
||||
type SpawnFn = typeof import("node:child_process").spawn;
|
||||
let _spawn: SpawnFn | undefined;
|
||||
const getSpawn = (): SpawnFn => {
|
||||
if (_spawn === undefined) {
|
||||
_spawn = require("node:child_process").spawn as SpawnFn;
|
||||
}
|
||||
return _spawn;
|
||||
};
|
||||
|
||||
export interface GitCheckoutStatus {
|
||||
present: boolean;
|
||||
remoteUrl: string | null;
|
||||
@ -32,7 +23,6 @@ export type GitRunner = (args: string[], cwd: string) => Promise<GitRunnerResult
|
||||
|
||||
export async function defaultGitRunner(args: string[], cwd: string): Promise<GitRunnerResult> {
|
||||
return new Promise((resolve) => {
|
||||
const spawn = getSpawn();
|
||||
const child = spawn("git", args, { cwd, env: process.env });
|
||||
let output = "";
|
||||
child.stdout.on("data", (chunk: Buffer | string) => {
|
||||
|
||||
@ -562,19 +562,18 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
||||
).toMatchObject({ content: "owner-only-memory" });
|
||||
|
||||
// The user profile is per user: another user's fact ids and spaces are out of reach.
|
||||
const ownerProfile = await rpc<{ facts: Array<{ id: string; status: string }> }>(
|
||||
app,
|
||||
owner,
|
||||
"profile/update",
|
||||
{
|
||||
addFacts: [
|
||||
{ category: "work", value: "Owner works nights" },
|
||||
{ category: "health", value: "Owner health note", source: "onboarding" },
|
||||
],
|
||||
},
|
||||
);
|
||||
const ownerFactIds = ownerProfile.facts.map((fact) => fact.id);
|
||||
expect(ownerProfile.facts.map((fact) => fact.status).sort()).toEqual(["active", "pending"]);
|
||||
const ownerProfile = await rpc<{
|
||||
facts: Array<{ id: string; status: string; source: string }>;
|
||||
}>(app, owner, "profile/update", {
|
||||
addFacts: [
|
||||
{ category: "work", value: "Owner works nights" },
|
||||
{ category: "health", value: "Owner health note", source: "onboarding" },
|
||||
],
|
||||
});
|
||||
// Connecting an app above already remembered it; this check is about the facts added here.
|
||||
const ownerFacts = ownerProfile.facts.filter((fact) => fact.source !== "connector");
|
||||
const ownerFactIds = ownerFacts.map((fact) => fact.id);
|
||||
expect(ownerFacts.map((fact) => fact.status).sort()).toEqual(["active", "pending"]);
|
||||
const profileCalls = [
|
||||
["profile/update", { confirmFactIds: ownerFactIds }],
|
||||
["profile/update", { editFacts: [{ id: ownerFactIds[0], value: "Stolen" }] }],
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { loadRootEnv } from "@rakazo/core/node/load-root-env";
|
||||
import { PostgreSqlContainer } from "@testcontainers/postgresql";
|
||||
import type { createApp } from "../../../../apps/api/src/app.ts";
|
||||
import { writeOnboardingScanFixture } from "../onboarding-scan-fixture.js";
|
||||
import { runProcess } from "./process.js";
|
||||
|
||||
loadRootEnv();
|
||||
@ -106,6 +108,7 @@ async function main() {
|
||||
"packages/testkit/src/bot-secrets.test.ts",
|
||||
"packages/testkit/src/delegation.postgres.test.ts",
|
||||
"packages/testkit/src/user-profile.postgres.test.ts",
|
||||
"packages/testkit/src/onboarding-discovery.postgres.test.ts",
|
||||
"packages/db/src/space-membership.postgres.test.ts",
|
||||
"packages/db/src/main-agent.postgres.test.ts",
|
||||
"packages/db/src/user-profile.postgres.test.ts",
|
||||
@ -180,6 +183,10 @@ async function main() {
|
||||
{ fetch: thirdParties.fetch, resolveHostname: thirdParties.resolveHostname },
|
||||
);
|
||||
const email = new EmailEmulator();
|
||||
// A fixture home folder, so the deployment owner's onboarding offers the real project scan.
|
||||
const scanHome = await mkdtemp(path.join(tmpdir(), "rakazo-e2e-scan-home-"));
|
||||
await writeOnboardingScanFixture(scanHome);
|
||||
process.env.ONBOARDING_SCAN_HOME = scanHome;
|
||||
const handles = await createApp({
|
||||
databaseUrl,
|
||||
prisma: undefined,
|
||||
@ -192,13 +199,30 @@ async function main() {
|
||||
},
|
||||
integrationsCatalogUrl: "https://catalog.example.test/",
|
||||
});
|
||||
// No spec user owns the deployment unless it asks (POST /__e2e/deployment-owner), so
|
||||
// owner-only onboarding steps appear only in the specs that test them, whatever runs first.
|
||||
await handles.prisma.deploymentSettings.upsert({
|
||||
where: { id: "default" },
|
||||
create: {
|
||||
id: "default",
|
||||
ownerUserId: E2E_NO_OWNER,
|
||||
signupsEnabled: true,
|
||||
signupAllowlist: "",
|
||||
signupPolicyInitialized: true,
|
||||
},
|
||||
update: { ownerUserId: E2E_NO_OWNER },
|
||||
});
|
||||
let activeRequests = 0;
|
||||
const requestWaiters = new Set<() => void>();
|
||||
const server = serve({
|
||||
fetch: async (request) => {
|
||||
if (new URL(request.url).pathname === "/__e2e/emails") {
|
||||
const { pathname } = new URL(request.url);
|
||||
if (pathname === "/__e2e/emails") {
|
||||
return Response.json(email.sent, { headers: { "cache-control": "no-store" } });
|
||||
}
|
||||
if (pathname === "/__e2e/deployment-owner" && request.method === "POST") {
|
||||
return Response.json(await setDeploymentOwner(handles, await request.json()));
|
||||
}
|
||||
activeRequests += 1;
|
||||
try {
|
||||
return await handles.app.fetch(request);
|
||||
@ -266,6 +290,7 @@ async function main() {
|
||||
await new Promise<void>((resolve) => requestWaiters.add(resolve));
|
||||
}
|
||||
await handles.stop().catch(() => undefined);
|
||||
await rm(scanHome, { recursive: true, force: true }).catch(() => undefined);
|
||||
for (let index = 0; index < computers.length; index += 4) {
|
||||
const results = await Promise.allSettled(
|
||||
computers.slice(index, index + 4).map((computer) =>
|
||||
@ -304,6 +329,9 @@ async function main() {
|
||||
|
||||
type AppHandles = Awaited<ReturnType<typeof createApp>>;
|
||||
|
||||
/** Matches no user, so the first signup does not claim the deployment. */
|
||||
const E2E_NO_OWNER = "e2e-no-owner";
|
||||
|
||||
async function managedComputers(handles: AppHandles) {
|
||||
if (!["e2b", "daytona", "box"].includes(sandboxProvider)) return [];
|
||||
return handles.prisma.computer.findMany({
|
||||
@ -312,6 +340,33 @@ async function managedComputers(handles: AppHandles) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets a spec act as the deployment owner (by email) for owner-only steps, then restore the
|
||||
* previous owner (by id). Returns the owner it replaced.
|
||||
*/
|
||||
async function setDeploymentOwner(
|
||||
handles: AppHandles,
|
||||
input: { email?: string; userId?: string | null },
|
||||
): Promise<{ previousOwnerUserId: string | null }> {
|
||||
const previous = await handles.prisma.deploymentSettings.findUnique({
|
||||
where: { id: "default" },
|
||||
select: { ownerUserId: true },
|
||||
});
|
||||
const ownerUserId = input.email
|
||||
? (
|
||||
await handles.prisma.user.findUniqueOrThrow({
|
||||
where: { email: input.email },
|
||||
select: { id: true },
|
||||
})
|
||||
).id
|
||||
: (input.userId ?? E2E_NO_OWNER);
|
||||
await handles.prisma.deploymentSettings.update({
|
||||
where: { id: "default" },
|
||||
data: { ownerUserId },
|
||||
});
|
||||
return { previousOwnerUserId: previous?.ownerUserId ?? null };
|
||||
}
|
||||
|
||||
async function writeSummary(reportDir: string, summary: Record<string, unknown>) {
|
||||
await writeFile(
|
||||
path.join(reportDir, "summary.json"),
|
||||
|
||||
363
packages/testkit/src/onboarding-discovery.postgres.test.ts
Normal file
363
packages/testkit/src/onboarding-discovery.postgres.test.ts
Normal file
@ -0,0 +1,363 @@
|
||||
import { mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
ComposioEmulator,
|
||||
PipedreamConnector,
|
||||
ThirdPartyConnectorEmulator,
|
||||
} from "@rakazo/adapters";
|
||||
import type {
|
||||
OnboardingScanApplyResult,
|
||||
OnboardingScanStatus,
|
||||
SpaceNavigation,
|
||||
UserProfileState,
|
||||
} from "@rakazo/contracts";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { createApp } from "../../../apps/api/src/app.ts";
|
||||
import { sessionCookieHeader } from "./index.js";
|
||||
import { writeOnboardingScanFixture } from "./onboarding-scan-fixture.js";
|
||||
|
||||
type App = { request: (input: string, init?: RequestInit) => Response | Promise<Response> };
|
||||
type AppHandles = Awaited<ReturnType<typeof createApp>>;
|
||||
type Actor = { userId: string; spaceId: string };
|
||||
|
||||
process.env.WAKEUP_DRIVER = "memory";
|
||||
process.env.SANDBOX_PROVIDER = "fake";
|
||||
process.env.AGENT_RUNTIME = "scripted";
|
||||
|
||||
const hasDb = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL);
|
||||
const describeWithDatabase = hasDb ? describe : describe.skip;
|
||||
const TEST_ENCRYPTION_KEY = "offline-discovery-test-encryption-key";
|
||||
|
||||
describeWithDatabase("onboarding discovery through the API", () => {
|
||||
let handles: AppHandles;
|
||||
let app: App;
|
||||
let composio: ComposioEmulator;
|
||||
const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-discovery-"));
|
||||
const home = realpathSync(mkdtempSync(path.join(tmpdir(), "rakazo-scan-home-")));
|
||||
const outside = realpathSync(mkdtempSync(path.join(tmpdir(), "rakazo-scan-outside-")));
|
||||
|
||||
beforeAll(async () => {
|
||||
await writeOnboardingScanFixture(home);
|
||||
const { createApp } = await import("../../../apps/api/src/app.ts");
|
||||
composio = new ComposioEmulator();
|
||||
const thirdParties = new ThirdPartyConnectorEmulator();
|
||||
handles = await createApp({
|
||||
databaseUrl: process.env.DATABASE_URL!,
|
||||
dataDir,
|
||||
sandboxProvider: "fake",
|
||||
agentRuntime: "scripted",
|
||||
wakeupDriver: "memory",
|
||||
signupsEnabled: "true",
|
||||
encryptionKey: TEST_ENCRYPTION_KEY,
|
||||
onboardingScanHome: home,
|
||||
composio,
|
||||
pipedream: new PipedreamConnector(
|
||||
{
|
||||
clientId: "fake-client-id",
|
||||
clientSecret: "fake-client-secret",
|
||||
projectId: "fake-project-id",
|
||||
environment: "development",
|
||||
identitySecret: TEST_ENCRYPTION_KEY,
|
||||
},
|
||||
{ fetch: thirdParties.fetch, resolveHostname: thirdParties.resolveHostname },
|
||||
),
|
||||
remoteConnectors: {
|
||||
fetch: thirdParties.fetch,
|
||||
resolveHostname: thirdParties.resolveHostname,
|
||||
},
|
||||
});
|
||||
app = handles.app;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await handles?.stop();
|
||||
for (const dir of [dataDir, home, outside]) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function account(label: string): Promise<{ cookie: string; actor: Actor }> {
|
||||
const cookie = await signup(app, `${label}-${stamp}@rakazo.test`, label);
|
||||
return { cookie, actor: await rpc<Actor>(app, cookie, "me") };
|
||||
}
|
||||
|
||||
async function makeDeploymentOwner(actor: Actor): Promise<void> {
|
||||
await handles.prisma.deploymentSettings.update({
|
||||
where: { id: "default" },
|
||||
data: { ownerUserId: actor.userId },
|
||||
});
|
||||
}
|
||||
|
||||
async function finishedScan(cookie: string) {
|
||||
const { scanId } = await rpc<{ scanId: string }>(app, cookie, "onboarding/scan");
|
||||
const status = await vi.waitFor(
|
||||
async () => {
|
||||
const current = await rpc<OnboardingScanStatus>(app, cookie, "onboarding/scanStatus", {
|
||||
scanId,
|
||||
});
|
||||
if (current.status === "scanning") throw new Error("still scanning");
|
||||
return current;
|
||||
},
|
||||
{ timeout: 10_000, interval: 50 },
|
||||
);
|
||||
if (status.status !== "complete") throw new Error("scan failed");
|
||||
return { scanId, ...status };
|
||||
}
|
||||
|
||||
it("offers the scan only to the deployment owner and keeps it with them", async () => {
|
||||
const owner = await account("discovery-owner");
|
||||
const other = await account("discovery-other");
|
||||
await makeDeploymentOwner(owner.actor);
|
||||
|
||||
await expect(rpc(app, other.cookie, "onboarding/scanTarget")).resolves.toEqual({
|
||||
folder: null,
|
||||
});
|
||||
expect((await raw(app, other.cookie, "onboarding/scan")).status).toBe(403);
|
||||
await expect(rpc(app, owner.cookie, "onboarding/scanTarget")).resolves.toEqual({
|
||||
folder: home,
|
||||
});
|
||||
|
||||
const scan = await finishedScan(owner.cookie);
|
||||
expect(scan.projects).toEqual([
|
||||
{ id: "project-0", name: "atlas", path: "code/atlas" },
|
||||
{ id: "project-1", name: "garden", path: "code/garden" },
|
||||
]);
|
||||
expect(scan.facts.map((fact) => fact.label)).toEqual([
|
||||
"Python",
|
||||
"TypeScript",
|
||||
"Docker",
|
||||
"GitHub Actions",
|
||||
]);
|
||||
expect(
|
||||
(await raw(app, other.cookie, "onboarding/scanStatus", { scanId: scan.scanId })).status,
|
||||
).toBe(404);
|
||||
expect(
|
||||
(
|
||||
await raw(app, other.cookie, "onboarding/applyScan", {
|
||||
scanId: scan.scanId,
|
||||
projectIds: ["project-0"],
|
||||
factIds: ["language:Python"],
|
||||
})
|
||||
).status,
|
||||
).toBe(404);
|
||||
await expect(rpc<UserProfileState>(app, other.cookie, "profile/get")).resolves.toMatchObject({
|
||||
facts: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("creates the chosen projects in the owner's own spaces and saves only the chosen facts", async () => {
|
||||
const owner = await account("discovery-creator");
|
||||
const intruder = await account("discovery-intruder");
|
||||
await makeDeploymentOwner(owner.actor);
|
||||
const atlasFolder = path.join(home, "code/atlas");
|
||||
// Another tenant already linked a space with the same name to the same folder.
|
||||
const foreign = await rpc<{ id: string }>(app, intruder.cookie, "spaces/create", {
|
||||
name: "atlas",
|
||||
workspacePath: atlasFolder,
|
||||
});
|
||||
|
||||
const scan = await finishedScan(owner.cookie);
|
||||
const choice = {
|
||||
scanId: scan.scanId,
|
||||
projectIds: ["project-0"],
|
||||
factIds: ["language:TypeScript", "tool:Docker"],
|
||||
};
|
||||
const applied = await rpc<OnboardingScanApplyResult>(
|
||||
app,
|
||||
owner.cookie,
|
||||
"onboarding/applyScan",
|
||||
choice,
|
||||
);
|
||||
|
||||
expect(applied.factsAdded).toBe(2);
|
||||
expect(applied.spaces).toEqual([{ id: expect.any(String), name: "atlas" }]);
|
||||
const created = applied.spaces[0]!.id;
|
||||
expect(created).not.toBe(foreign.id);
|
||||
const ownerOrg = await handles.prisma.space.findUniqueOrThrow({
|
||||
where: { id: owner.actor.spaceId },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
await expect(
|
||||
handles.prisma.space.findUniqueOrThrow({
|
||||
where: { id: created },
|
||||
select: { organizationId: true, workspacePath: true },
|
||||
}),
|
||||
).resolves.toEqual({ organizationId: ownerOrg.organizationId, workspacePath: atlasFolder });
|
||||
await expect(
|
||||
handles.prisma.spaceMember.findMany({
|
||||
where: { spaceId: { in: [created, foreign.id] } },
|
||||
select: { spaceId: true, userId: true, role: true },
|
||||
orderBy: { spaceId: "asc" },
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ spaceId: created, userId: owner.actor.userId, role: "owner" },
|
||||
{ spaceId: foreign.id, userId: intruder.actor.userId, role: "owner" },
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
await handles.prisma.spaceMember.count({ where: { spaceId: { in: [created, foreign.id] } } }),
|
||||
).toBe(2);
|
||||
await expect(
|
||||
handles.prisma.memoryDocument.count({
|
||||
where: { spaceId: created, userId: owner.actor.userId, scope: "user" },
|
||||
}),
|
||||
).resolves.toBe(1);
|
||||
|
||||
const ownerSpaces = await rpc<SpaceNavigation>(app, owner.cookie, "spaces/list");
|
||||
expect(ownerSpaces.spaces).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: created,
|
||||
name: "atlas",
|
||||
workspace: expect.objectContaining({ path: atlasFolder }),
|
||||
}),
|
||||
);
|
||||
expect(ownerSpaces.spaces.map((space) => space.id)).not.toContain(foreign.id);
|
||||
const intruderSpaces = await rpc<SpaceNavigation>(app, intruder.cookie, "spaces/list");
|
||||
expect(intruderSpaces.spaces.map((space) => space.id)).not.toContain(created);
|
||||
|
||||
const profile = await rpc<UserProfileState>(app, owner.cookie, "profile/get");
|
||||
expect(
|
||||
profile.facts.map(({ category, value, source }) => ({ category, value, source })),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ category: "work", value: "Codes in TypeScript", source: "onboarding" },
|
||||
{ category: "tools", value: "Uses Docker", source: "onboarding" },
|
||||
]),
|
||||
);
|
||||
expect(profile.facts).toHaveLength(2);
|
||||
await expect(rpc<UserProfileState>(app, intruder.cookie, "profile/get")).resolves.toMatchObject(
|
||||
{ facts: [] },
|
||||
);
|
||||
|
||||
// Applying again reuses the space and the facts.
|
||||
await expect(
|
||||
rpc<OnboardingScanApplyResult>(app, owner.cookie, "onboarding/applyScan", choice),
|
||||
).resolves.toEqual({ spaces: [{ id: created, name: "atlas" }], factsAdded: 0 });
|
||||
expect(await handles.prisma.spaceMember.count({ where: { userId: owner.actor.userId } })).toBe(
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a chosen project that left the scan folder and writes nothing", async () => {
|
||||
const owner = await account("discovery-escape");
|
||||
await makeDeploymentOwner(owner.actor);
|
||||
const scan = await finishedScan(owner.cookie);
|
||||
await mkdir(path.join(outside, "elsewhere", ".git"), { recursive: true });
|
||||
await rm(path.join(home, "code/garden"), { recursive: true, force: true });
|
||||
symlinkSync(path.join(outside, "elsewhere"), path.join(home, "code/garden"));
|
||||
|
||||
const response = await raw(app, owner.cookie, "onboarding/applyScan", {
|
||||
scanId: scan.scanId,
|
||||
projectIds: ["project-1"],
|
||||
factIds: ["language:Python"],
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
expect(await handles.prisma.spaceMember.count({ where: { userId: owner.actor.userId } })).toBe(
|
||||
1,
|
||||
);
|
||||
await expect(rpc<UserProfileState>(app, owner.cookie, "profile/get")).resolves.toMatchObject({
|
||||
facts: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("remembers each connected app once as a tools fact and keeps it after revoking", async () => {
|
||||
const user = await account("discovery-apps");
|
||||
const other = await account("discovery-apps-other");
|
||||
// A featured app is named by its label, whatever the account is called.
|
||||
const gmail = await rpc<{ connectionId: string }>(app, user.cookie, "connections/begin", {
|
||||
connectorId: "composio",
|
||||
provider: "GMAIL",
|
||||
displayName: "Personal",
|
||||
});
|
||||
// Other apps take the first account's name; a second account adds nothing.
|
||||
for (const displayName of ["GitHub", "GitHub 2"]) {
|
||||
await rpc(app, user.cookie, "connections/begin", {
|
||||
connectorId: "composio",
|
||||
provider: "GITHUB",
|
||||
displayName,
|
||||
});
|
||||
}
|
||||
// Connected through the authorization round trip.
|
||||
const linear = await rpc<{ connectionId: string; authorizationUrl: string | null }>(
|
||||
app,
|
||||
user.cookie,
|
||||
"connections/begin",
|
||||
{ connectorId: "pipedream", provider: "linear", displayName: "Linear" },
|
||||
);
|
||||
expect(linear.authorizationUrl).not.toBeNull();
|
||||
await expect(
|
||||
rpc<{ status: string }>(app, user.cookie, "connections/complete", {
|
||||
connectionId: linear.connectionId,
|
||||
}),
|
||||
).resolves.toMatchObject({ status: "connected" });
|
||||
// Connected remotely, then noticed when the catalog reconciles a pending row.
|
||||
await handles.prisma.connection.create({
|
||||
data: {
|
||||
spaceId: user.actor.spaceId,
|
||||
userId: user.actor.userId,
|
||||
provider: "SLACK",
|
||||
displayName: "SLACK",
|
||||
status: "pending",
|
||||
},
|
||||
});
|
||||
await composio.begin(
|
||||
{ provider: "SLACK", redirectUrl: "http://127.0.0.1.invalid/callback" },
|
||||
{
|
||||
operationId: "discovery-test",
|
||||
traceId: "discovery-test",
|
||||
spaceId: user.actor.spaceId,
|
||||
userId: user.actor.userId,
|
||||
signal: new AbortController().signal,
|
||||
},
|
||||
);
|
||||
await rpc(app, user.cookie, "connections/catalog", { connectorId: "composio" });
|
||||
await rpc(app, user.cookie, "connections/revoke", { connectionId: gmail.connectionId });
|
||||
|
||||
const profile = await rpc<UserProfileState>(app, user.cookie, "profile/get");
|
||||
expect(
|
||||
profile.facts
|
||||
.map(({ category, value, source }) => ({ category, value, source }))
|
||||
.sort((left, right) => left.value.localeCompare(right.value)),
|
||||
).toEqual([
|
||||
{ category: "tools", value: "Uses GitHub", source: "connector" },
|
||||
{ category: "tools", value: "Uses Gmail", source: "connector" },
|
||||
{ category: "tools", value: "Uses Linear", source: "connector" },
|
||||
{ category: "tools", value: "Uses Slack", source: "connector" },
|
||||
]);
|
||||
await expect(rpc<UserProfileState>(app, other.cookie, "profile/get")).resolves.toMatchObject({
|
||||
facts: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function signup(app: App, email: string, name: string) {
|
||||
const response = await app.request("/api/auth/sign-up/email", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", origin: "http://127.0.0.1:5173" },
|
||||
body: JSON.stringify({ email, password: "password12", name }),
|
||||
});
|
||||
if (response.status >= 400) {
|
||||
throw new Error(`signup failed ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
return sessionCookieHeader(response);
|
||||
}
|
||||
|
||||
async function raw(app: App, cookie: string, procedure: string, body: unknown = {}) {
|
||||
return app.request(`/rpc/${procedure}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", cookie, origin: "http://127.0.0.1:5173" },
|
||||
body: JSON.stringify({ json: body }),
|
||||
});
|
||||
}
|
||||
|
||||
async function rpc<T>(app: App, cookie: string, procedure: string, body: unknown = {}) {
|
||||
const response = await raw(app, cookie, procedure, body);
|
||||
const text = await response.text();
|
||||
const payload = JSON.parse(text) as { json?: T; error?: { message?: string } };
|
||||
if (response.status >= 400 || payload.error) {
|
||||
throw new Error(`${procedure} ${response.status}: ${payload.error?.message ?? text}`);
|
||||
}
|
||||
return payload.json as T;
|
||||
}
|
||||
35
packages/testkit/src/onboarding-scan-fixture.ts
Normal file
35
packages/testkit/src/onboarding-scan-fixture.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
/** An empty but valid git directory, so git commands stop here instead of searching upwards. */
|
||||
async function writeGitDirectory(project: string): Promise<void> {
|
||||
await mkdir(path.join(project, ".git", "objects"), { recursive: true });
|
||||
await mkdir(path.join(project, ".git", "refs"), { recursive: true });
|
||||
await writeFile(path.join(project, ".git", "HEAD"), "ref: refs/heads/main\n");
|
||||
}
|
||||
|
||||
async function writeFiles(root: string, files: Record<string, string>): Promise<void> {
|
||||
for (const [relative, content] of Object.entries(files)) {
|
||||
const file = path.join(root, relative);
|
||||
await mkdir(path.dirname(file), { recursive: true });
|
||||
await writeFile(file, content);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A home folder for the onboarding scan: two projects ("code/atlas" in TypeScript with Docker and
|
||||
* GitHub Actions, "code/garden" in Python) and a notes folder that is not a project.
|
||||
*/
|
||||
export async function writeOnboardingScanFixture(home: string): Promise<void> {
|
||||
await writeFiles(home, {
|
||||
"code/atlas/src/index.ts": "export const atlas = 1;\n",
|
||||
"code/atlas/src/app.tsx": "export const App = () => null;\n",
|
||||
"code/atlas/Dockerfile": "FROM scratch\n",
|
||||
"code/atlas/.github/workflows/ci.yml": "on: push\n",
|
||||
"code/garden/main.py": "print('garden')\n",
|
||||
"code/garden/util.py": "VALUE = 1\n",
|
||||
"notes/todo.md": "- water the plants\n",
|
||||
});
|
||||
await writeGitDirectory(path.join(home, "code/atlas"));
|
||||
await writeGitDirectory(path.join(home, "code/garden"));
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user