diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16de0d3..2c541d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,8 +25,8 @@ jobs: - uses: actions/setup-node@v4 with: - # pnpm 11 needs Node >= 22.13 (uses node:sqlite) - node-version: 22 + # Node 24 is the current LTS. pnpm 11 needs >= 22.13 (node:sqlite). + node-version: 24 cache: pnpm - name: Install @@ -82,7 +82,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 24 cache: pnpm - uses: dart-lang/setup-dart@v1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5e70d63..b5f8b2a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 24 cache: pnpm - name: Check tag matches extension version diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/README.md b/README.md index 55e19a5..413eaa7 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ It is not a Postman replacement. It exists to keep your API contract, its histor ## Features - **GUI first.** The Apilot panel has a request editor, response view, **Changes** (split diff of baseline vs latest, with breaking/warning/safe labels and code impact), **History** (every saved response; set any as the baseline), **Versions** (v1, v2, … of the request definition, with diff and restore), **Schema**, **Code**, **Environments**, **Import**, and **Run all**. +- **Several projects in one workspace.** A folder can hold more than one Apilot project — the folder itself and any nested folder with an `.apilot/apilot.yaml`. All of them are found and shown; the sidebar lists one group per project, and a folder without Apilot still offers to initialize. - **Breaking-change detection.** Removed fields, type changes, non-null becoming nullable, and removed enum values are breaking. Added fields are safe. - **Lightweight version control for endpoints.** Every save is recorded in `.apilot/history/`, whether it comes from the GUI, a hand edit, or the AI. - **Codegen.** Dart (freezed or plain + Dio), TypeScript (zod + fetch), Kotlin (kotlinx + Retrofit), and Swift (Codable). Quicktype-style: identical shapes share one class, array items get singular names, repeated strings become enums with an `unknown` fallback, ISO dates become date types, and id-keyed objects become maps. Files you wrote by hand are never overwritten. @@ -17,6 +18,28 @@ It is not a Postman replacement. It exists to keep your API contract, its histor - **AI with no setup.** The extension writes skills to `.cursor/skills/`, `.claude/skills/`, and `.github/instructions/`, and registers the MCP server (24 tools) with Cursor or VS Code. There is no "connect" button. - **CI.** `apilot check` fails the build on errors or breaking changes. +## Several projects in one workspace + +Apilot discovers every `.apilot/apilot.yaml` under the folders you open (4 levels deep, skipping `node_modules`, `build`, `dist`, and other noise folders). Each project keeps its own endpoints, environments, snapshots, and history. + +- The **sidebar** shows one group per project. A folder with no Apilot yet is listed with an *init* action. +- The **panel** keeps track of which project you are in, along with that project's environments and secrets. +- The **AI** gets the whole picture: `list_endpoints` returns each project with its id, and every endpoint tool takes an optional `project`: + +```jsonc +// ids that are unique in the workspace need no project +{ "endpointId": "orders.list" } + +// ids that exist in two projects do +{ "endpointId": "orders.list", "project": "services/api" } + +// or as one string +{ "endpointId": "services/api::orders.list" } +``` + +If an id exists in more than one project and no `project` is given, Apilot says so instead of guessing. + + ## Secrets Secret values never touch files, git, or the AI. @@ -32,7 +55,7 @@ Snapshots are redacted before they are written. The AI can declare a secret vari | Package | Description | |---|---| -| `@apilot/core` | File format, `Workspace` (the shared action layer), runner, snapshots, differ, schema, revisions, importers, redaction | +| `@apilot/core` | File format, `Workspace` (the shared action layer), `ProjectHub` (several projects in one workspace), runner, snapshots, differ, schema, revisions, importers, redaction | | `@apilot/codegen` | Model and API-call generators | | `@apilot/mcp` | MCP server and tool table, shared with the extension | | `@apilot/cli` | `apilot` CLI for CI and scripts | @@ -57,6 +80,8 @@ apilot check --env dev --markdown "$GITHUB_STEP_SUMMARY" apilot setup-ai # skills for editors without the extension ``` +Add `--project ` to any command to target one project (for example `apilot run orders.list --project services/api`). + ## MCP without the extension The extension registers the server itself. For other clients: @@ -87,6 +112,8 @@ When the editor is open, the MCP server forwards calls to the extension over a l .local/ # machine-local state, gitignored ``` +Each project has its own `.apilot/` folder. A workspace can hold several of them — one per project — and the extension finds them all. + ## Install the extension Download the latest `.vsix` from [GitHub Releases](https://github.com/Darkmintis/Apilot/releases), then: diff --git a/package.json b/package.json index 3b324c0..ba949ea 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "publish:packages": "pnpm -r --filter '@apilot/*' publish --access public --no-git-checks" }, "engines": { - "node": ">=22.13", + "node": ">=24", "pnpm": ">=11" }, "dependencies": { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8d39397..db1b32d 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,23 +2,29 @@ /** * Apilot CLI — for CI and scripts. Same Workspace as the editor panel and * the MCP server; secrets come from APILOT_SECRET_ environment variables. + * + * A folder can hold several Apilot projects. Every command accepts + * `--project ` to pick one; without it the endpoint id is looked up + * across all of them. */ import { Command } from "commander"; import { existsSync, readFileSync, writeFileSync, appendFileSync } from "node:fs"; import { dirname, join } from "node:path"; -import { Workspace, importSpec, type DiffResult, type ImportSource } from "@apilot/core"; +import { ProjectHub, Workspace, importSpec, type DiffResult, type ImportSource, type ProjectEntry } from "@apilot/core"; import { LANGUAGES, generate, writeGenerated } from "@apilot/codegen"; import { writeAiFiles } from "@apilot/skill"; function findRoot(): string { + const fromEnv = process.env.APILOT_PROJECT_ROOT; + if (fromEnv) return fromEnv; for (let dir = process.cwd(); ; dir = dirname(dir)) { if (existsSync(join(dir, ".apilot", "apilot.yaml"))) return dir; if (dirname(dir) === dir) return process.cwd(); } } -const ws = new Workspace(findRoot()); +const hub = ProjectHub.forRoot(findRoot()); const program = new Command(); program.name("apilot").description("Run, diff, version, and generate code from your API endpoints.").version("0.1.0"); @@ -31,10 +37,26 @@ function printDiff(diff: DiffResult): void { for (const c of diff.changes) console.log(` ${icon(c.level)} ${c.path} — ${c.description}`); } +/** The project a command applies to. */ +function projectOf(opts: { project?: string }): ProjectEntry { + return opts.project ? hub.project(opts.project) : hub.defaultProject(); +} + +/** The endpoint a command applies to, and the project that owns it. */ +function endpointOf(id: string, opts: { project?: string }): { project: ProjectEntry; ws: Workspace; endpointId: string } { + const r = hub.resolveEndpoint(id, opts.project); + return { project: r.project, ws: r.ws, endpointId: r.endpointId }; +} + +/** Every project that has an `.apilot` folder. */ +function initialized(): ProjectEntry[] { + return hub.projects.filter((p) => p.hasApilot); +} + function action(fn: (...args: A) => Promise | void, needsProject = true) { return async (...args: A) => { try { - if (needsProject && !ws.exists()) throw new Error("No .apilot/ project found. Run `apilot init` first."); + if (needsProject && !hub.exists()) throw new Error("No .apilot/ project found. Run `apilot init` first."); await fn(...args); } catch (err) { console.error(`✗ ${err instanceof Error ? err.message : String(err)}`); @@ -47,33 +69,46 @@ program .command("init") .description("Create a starter .apilot/ folder") .option("-n, --name ", "project name") - .action(action((opts: { name?: string }) => { - ws.init(opts.name); - console.log(`✓ Apilot project ready at ${ws.apilotDir}`); + .option("-p, --project ", "project id (defaults to the first folder without Apilot)") + .action(action((opts: { name?: string; project?: string }) => { + const r = hub.initProject(opts.name ?? "my-api", opts.project); + console.log(`✓ Apilot project ready at ${r.initialized}`); }, false)); program .command("setup-ai") .description("Add Apilot skills for Cursor, Claude Code, and Copilot to this project (the editor extension does this automatically)") .action(action(() => { - const written = writeAiFiles(ws.root); + const root = hub.defaultProject().root; + const written = writeAiFiles(root); console.log(written.length ? written.map((f) => `✓ ${f}`).join("\n") : "✓ AI skill files already up to date"); - console.log(`\nMCP server (for clients without the extension):\n command: npx\n args: ["-y", "@apilot/mcp"]\n env: { "APILOT_PROJECT_ROOT": "${ws.root}" }`); + console.log(`\nMCP server (for clients without the extension):\n command: npx\n args: ["-y", "@apilot/mcp"]\n env: { "APILOT_PROJECT_ROOT": "${root}" }`); })); program .command("list") .description("List endpoints") - .action(action(() => { - for (const e of ws.registry().endpoints) console.log(`${e.method.padEnd(7)} ${e.id.padEnd(32)} ${e.url}`); + .option("-p, --project ", "only this project") + .action(action((opts: { project?: string }) => { + const rows = hub.listEndpoints(opts.project); + if (rows.length && hub.projects.length > 1) { + console.log(`${"METHOD".padEnd(7)} ${"ENDPOINT".padEnd(32)} ${"PROJECT".padEnd(20)} URL`); + } + for (const { project, endpoint } of rows) { + console.log( + `${endpoint.method.padEnd(7)} ${endpoint.id.padEnd(32)} ${(hub.projects.length > 1 ? project.id.padEnd(20) : "")}${endpoint.url}` + ); + } })); program .command("run ") .description("Run one endpoint and save the (redacted) response") .option("-e, --env ", "environment") - .action(action(async (id: string, opts: { env?: string }) => { - const r = await ws.run(id, opts.env); + .option("-p, --project ", "project id") + .action(action(async (id: string, opts: { env?: string; project?: string }) => { + const { ws, endpointId } = endpointOf(id, opts); + const r = await ws.run(endpointId, opts.env); const s = r.snapshot; console.log(`${s.passed ? "✓" : "✗"} ${s.request.method} ${s.request.url} → ${s.status} (${s.timeMs} ms)`); for (const f of s.failures) console.log(` ✗ ${f.message}`); @@ -87,16 +122,27 @@ program program .command("run-all [collectionId]") - .description("Run every endpoint (or one collection) in order") + .description("Run every endpoint (or one collection) in order, across every project") .option("-e, --env ", "environment") - .action(action(async (collectionId: string | undefined, opts: { env?: string }) => { - const r = await ws.runAll(collectionId, opts.env); - for (const x of r.results) { - const mark = x.error || !x.passed ? "✗" : x.breaking ? "⚠" : "✓"; - console.log(` ${mark} ${x.endpointId.padEnd(32)} ${x.error ?? `${x.status} (${x.timeMs} ms)${x.breaking ? " — breaking changes" : ""}`}`); + .option("-p, --project ", "only this project") + .action(action(async (collectionId: string | undefined, opts: { env?: string; project?: string }) => { + const projects = opts.project ? [hub.project(opts.project)] : initialized(); + let passed = 0; + let failed = 0; + let breaking = 0; + for (const project of projects) { + const r = await hub.workspace(project.id).runAll(collectionId, opts.env); + for (const x of r.results) { + const mark = x.error || !x.passed ? "✗" : x.breaking ? "⚠" : "✓"; + const label = projects.length > 1 ? `${x.endpointId} [${project.id}]` : x.endpointId; + console.log(` ${mark} ${label.padEnd(40)} ${x.error ?? `${x.status} (${x.timeMs} ms)${x.breaking ? " — breaking changes" : ""}`}`); + } + passed += r.passed; + failed += r.failed; + breaking += r.breaking; } - console.log(`\n${r.passed} passed, ${r.failed} failed, ${r.breaking} with breaking changes (env: ${r.env})`); - if (r.failed || r.breaking) process.exitCode = 1; + console.log(`\n${passed} passed, ${failed} failed, ${breaking} with breaking changes`); + if (failed || breaking) process.exitCode = 1; })); program @@ -104,11 +150,13 @@ program .description("Compare responses (default: baseline → latest)") .option("--from ") .option("--to ") - .action(action((id: string, opts: { from?: string; to?: string }) => { - const diff = ws.diff(id, opts.from, opts.to); - console.log(`Diff ${id}: ${diff.fromId} → ${diff.toId}`); + .option("-p, --project ", "project id") + .action(action((id: string, opts: { from?: string; to?: string; project?: string }) => { + const { ws, endpointId } = endpointOf(id, opts); + const diff = ws.diff(endpointId, opts.from, opts.to); + console.log(`Diff ${endpointId}: ${diff.fromId} → ${diff.toId}`); printDiff(diff); - const { matches } = ws.impact(id, diff); + const { matches } = ws.impact(endpointId, diff); if (matches.length) { console.log(`\nCode using changed fields:`); for (const m of matches) console.log(` ${m.file}:${m.line} [${m.field}] ${m.text}`); @@ -119,15 +167,19 @@ program program .command("baseline [snapshotId]") .description("Accept a response (default: latest) as the baseline") - .action(action((id: string, snapshotId?: string) => { - console.log(`✓ Baseline for ${id} is now ${ws.setBaseline(id, snapshotId)}`); + .option("-p, --project ", "project id") + .action(action((id: string, snapshotId: string | undefined, opts: { project?: string }) => { + const { ws, endpointId } = endpointOf(id, opts); + console.log(`✓ Baseline for ${endpointId} is now ${ws.setBaseline(endpointId, snapshotId)}`); })); program .command("history ") .description("Show the version history of an endpoint's request") - .action(action((id: string) => { - for (const r of ws.revisions(id)) console.log(` v${r.revision} ${r.timestamp.slice(0, 19).replace("T", " ")} ${r.label ? `[${r.label}] ` : ""}${r.summary}`); + .option("-p, --project ", "project id") + .action(action((id: string, opts: { project?: string }) => { + const { ws, endpointId } = endpointOf(id, opts); + for (const r of ws.revisions(endpointId)) console.log(` v${r.revision} ${r.timestamp.slice(0, 19).replace("T", " ")} ${r.label ? `[${r.label}] ` : ""}${r.summary}`); })); program @@ -136,8 +188,10 @@ program .option("-l, --lang ", "target language", "dart") .option("-o, --out ", "output directory (default: apilot.yaml codegen setting)") .option("--flavor ", "dart: freezed | plain") - .action(action((id: string, opts: { lang: string; out?: string; flavor?: string }) => { - const r = writeGenerated(ws.root, generate(ws, id, opts.lang, { outDir: opts.out, flavor: opts.flavor })); + .option("-p, --project ", "project id") + .action(action((id: string, opts: { lang: string; out?: string; flavor?: string; project?: string }) => { + const { ws, endpointId } = endpointOf(id, opts); + const r = writeGenerated(ws.root, generate(ws, endpointId, opts.lang, { outDir: opts.out, flavor: opts.flavor })); for (const f of r.written) console.log(`✓ wrote ${f}`); for (const f of r.unchanged) console.log(`· unchanged ${f}`); for (const f of r.skipped) console.log(`⚠ skipped ${f} (hand-written file — no Apilot header)`); @@ -148,10 +202,12 @@ program .description("Import endpoints: source = curl | postman | openapi") .option("-c, --collection ", "target collection") .option("-e, --env ", "environment for imported variables") - .action(action(async (source: string, file: string, opts: { collection?: string; env?: string }) => { + .option("-p, --project ", "target project") + .action(action(async (source: string, file: string, opts: { collection?: string; env?: string; project?: string }) => { if (!["curl", "postman", "openapi"].includes(source)) throw new Error("source must be curl, postman, or openapi"); const content = file === "-" ? readFileSync(0, "utf-8") : readFileSync(file, "utf-8"); - const r = await importSpec(ws, source as ImportSource, content, opts.collection, opts.env); + const project = projectOf(opts); + const r = await importSpec(hub.workspace(project.id), source as ImportSource, content, opts.collection, opts.env); for (const id of r.created) console.log(`✓ ${id}`); for (const n of r.notes) console.log(`⚠ ${n}`); })); @@ -160,18 +216,28 @@ program .command("check") .description("CI: run everything; fail on errors or breaking changes vs baseline") .option("-e, --env ", "environment") + .option("-p, --project ", "only this project") .option("--markdown ", "also write a Markdown summary (e.g. $GITHUB_STEP_SUMMARY)") - .action(action(async (opts: { env?: string; markdown?: string }) => { - const r = await ws.runAll(undefined, opts.env); + .action(action(async (opts: { env?: string; project?: string; markdown?: string }) => { + const projects = opts.project ? [hub.project(opts.project)] : initialized(); const rows: string[] = []; - for (const x of r.results) { - const mark = x.error || !x.passed ? "✗" : x.breaking ? "⚠" : "✓"; - console.log(` ${mark} ${x.endpointId} ${x.error ?? x.status}`); - rows.push(`| ${mark} | \`${x.endpointId}\` | ${x.error ? `error: ${x.error.replace(/\|/g, "\\|")}` : x.status} | ${x.summary ? `${x.summary.breaking} breaking, ${x.summary.warning} warning` : "—"} |`); - if (x.breaking) printDiff(ws.diff(x.endpointId)); + let passed = 0; + let failed = 0; + let breaking = 0; + for (const project of projects) { + const r = await hub.workspace(project.id).runAll(undefined, opts.env); + for (const x of r.results) { + const mark = x.error || !x.passed ? "✗" : x.breaking ? "⚠" : "✓"; + console.log(` ${mark} ${x.endpointId} ${x.error ?? x.status}`); + rows.push(`| ${mark} | \`${x.endpointId}\` | ${project.id} | ${x.error ? `error: ${x.error.replace(/\|/g, "\\|")}` : x.status} | ${x.summary ? `${x.summary.breaking} breaking, ${x.summary.warning} warning` : "—"} |`); + if (x.breaking) printDiff(hub.workspace(project.id).diff(x.endpointId)); + } + passed += r.passed; + failed += r.failed; + breaking += r.breaking; } - const ok = r.failed === 0 && r.breaking === 0; - const md = `## Apilot check — ${ok ? "✅ passed" : "❌ failed"}\n\n${r.passed} passed · ${r.failed} failed · ${r.breaking} breaking (env \`${r.env}\`)\n\n| | Endpoint | Status | Changes vs baseline |\n|---|---|---|---|\n${rows.join("\n")}\n`; + const ok = failed === 0 && breaking === 0; + const md = `## Apilot check — ${ok ? "✅ passed" : "❌ failed"}\n\n${passed} passed · ${failed} failed · ${breaking} breaking\n\n| | Endpoint | Project | Status | Changes vs baseline |\n|---|---|---|---|---|\n${rows.join("\n")}\n`; if (opts.markdown) (existsSync(opts.markdown) ? appendFileSync : writeFileSync)(opts.markdown, md); console.log(ok ? "\n✅ All checks passed." : "\n❌ Checks failed — errors or breaking changes detected."); if (!ok) process.exitCode = 1; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f830df7..4deabfd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -39,3 +39,16 @@ export { } from "./workspace.js"; export { importSpec, tokenizeShell, type ImportSource, type ImportResult } from "./importers.js"; + +export { + ProjectHub, + discoverProjects, + singleProjectContext, + isToolContext, + type ProjectEntry, + type ProjectEndpoint, + type ProjectHubOptions, + type ResolvedEndpoint, + type ToolContext, + type DiscoverOptions, +} from "./projects.js"; diff --git a/packages/core/src/projects.test.ts b/packages/core/src/projects.test.ts new file mode 100644 index 0000000..d257ef9 --- /dev/null +++ b/packages/core/src/projects.test.ts @@ -0,0 +1,187 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { stringify as toYaml } from "yaml"; +import { + ProjectHub, + discoverProjects, + singleProjectContext, + type ProjectEntry, +} from "../src/index"; + +const roots: string[] = []; + +function tempRoot(): string { + const dir = mkdtempSync(join(tmpdir(), "apilot-projects-")); + roots.push(dir); + return dir; +} + +/** Create a folder that holds an Apilot project. */ +function makeProject(dir: string, name = "My API", endpoints: string[] = []): void { + mkdirSync(join(dir, ".apilot"), { recursive: true }); + writeFileSync(join(dir, ".apilot", "apilot.yaml"), toYaml({ name, defaultEnvironment: "dev" })); + mkdirSync(join(dir, ".apilot", "collections", "orders"), { recursive: true }); + for (const ep of endpoints) { + writeFileSync( + join(dir, ".apilot", "collections", "orders", `${ep}.yaml`), + toYaml({ name: ep, method: "GET", url: "{{baseUrl}}/orders" }) + ); + } +} + +afterEach(() => { + for (const r of roots.splice(0)) { + try { + rmSync(r, { recursive: true, force: true }); + } catch { + /* best effort */ + } + } +}); + +describe("discoverProjects", () => { + it("always includes the workspace folder, even without .apilot", () => { + const root = tempRoot(); + const projects = discoverProjects([root]); + expect(projects).toHaveLength(1); + expect(projects[0]!.hasApilot).toBe(false); + expect(projects[0]!.root).toBe(root); + }); + + it("finds nested projects and marks them as initialized", () => { + const root = tempRoot(); + makeProject(join(root, "services", "api"), "Service API"); + makeProject(join(root, "apps", "web"), "Web App"); + + const projects = discoverProjects([root]); + const withApilot = projects.filter((p) => p.hasApilot); + expect(withApilot.map((p) => p.relPath).sort()).toEqual(["apps/web", "services/api"]); + expect(withApilot.find((p) => p.relPath === "services/api")!.name).toBe("Service API"); + }); + + it("does not descend into node_modules or hidden folders", () => { + const root = tempRoot(); + makeProject(join(root, "node_modules", "pkg")); + makeProject(join(root, ".hidden", "pkg")); + makeProject(join(root, "real")); + + const projects = discoverProjects([root]).filter((p) => p.hasApilot); + expect(projects.map((p) => p.relPath)).toEqual(["real"]); + }); + + it("stops at maxDepth", () => { + const root = tempRoot(); + makeProject(join(root, "a", "b", "c", "d")); + const shallow = discoverProjects([root], { maxDepth: 2 }).filter((p) => p.hasApilot); + expect(shallow).toHaveLength(0); + const deep = discoverProjects([root], { maxDepth: 5 }).filter((p) => p.hasApilot); + expect(deep.map((p) => p.relPath)).toEqual(["a/b/c/d"]); + }); + + it("gives every workspace folder its own project", () => { + const a = tempRoot(); + const b = tempRoot(); + makeProject(a); + makeProject(b); + const projects = discoverProjects([a, b]); + expect(projects).toHaveLength(2); + expect(new Set(projects.map((p) => p.id)).size).toBe(2); + }); + + it("lists a folder once when two workspace folders overlap", () => { + const root = tempRoot(); + const nested = join(root, "sub"); + makeProject(root); + makeProject(nested); + // "/sub" is inside "/root", and both are workspace folders. + const projects = discoverProjects([root, nested]); + const roots = projects.map((p) => p.root); + expect(new Set(roots).size).toBe(roots.length); + expect(projects.filter((p) => p.root === nested)).toHaveLength(1); + }); +}); + +describe("ProjectHub", () => { + function twoProjects() { + const root = tempRoot(); + makeProject(join(root, "services", "api"), "API", ["list"]); + makeProject(join(root, "apps", "web"), "Web", ["list"]); + return { root, hub: new ProjectHub(discoverProjects([root])) }; + } + + it("resolves an endpoint to the project that owns it", () => { + const { hub } = twoProjects(); + const resolved = hub.resolveEndpoint("orders.list", "apps/web"); + expect(resolved.project.id).toBe("apps/web"); + expect(resolved.endpointId).toBe("orders.list"); + }); + + it("reports an endpoint that exists in more than one project", () => { + const { hub } = twoProjects(); + expect(() => hub.resolveEndpoint("orders.list")).toThrow(/more than one project/); + }); + + it("accepts a project-qualified id", () => { + const { hub } = twoProjects(); + const resolved = hub.resolveEndpoint("services/api::orders.list"); + expect(resolved.project.id).toBe("services/api"); + }); + + it("lists endpoints across every project", () => { + const { hub } = twoProjects(); + const all = hub.listEndpoints(); + expect(all).toHaveLength(2); + expect(new Set(all.map((e) => e.project.id))).toEqual(new Set(["apps/web", "services/api"])); + }); + + it("initializes the folder that has no project yet", () => { + const root = tempRoot(); + makeProject(join(root, "services", "api")); + const hub = new ProjectHub(discoverProjects([root])); + expect(hub.exists()).toBe(true); + + const result = hub.initProject("Root API"); + expect(existsSync(join(root, ".apilot", "apilot.yaml"))).toBe(true); + expect(result.initialized).toBe(join(root, ".apilot")); + }); + + it("initializes a named project", () => { + const root = tempRoot(); + makeProject(join(root, "services", "api")); + const hub = new ProjectHub(discoverProjects([root])); + hub.initProject("Web API", "services/api"); + // It already had one — init is safe and keeps the existing files. + expect(existsSync(join(root, "services", "api", ".apilot", "apilot.yaml"))).toBe(true); + }); + + it("reports an unknown project", () => { + const { hub } = twoProjects(); + expect(() => hub.project("nope")).toThrow(/not found/); + }); + + it("initializes a project on demand when something is added to it", () => { + const root = tempRoot(); + const hub = new ProjectHub(discoverProjects([root])); + expect(hub.exists()).toBe(false); + + hub.ensureProject(); + expect(existsSync(join(root, ".apilot", "apilot.yaml"))).toBe(true); + expect(hub.exists()).toBe(true); + }); +}); + +describe("singleProjectContext", () => { + it("presents one workspace through the multi-project interface", () => { + const root = tempRoot(); + makeProject(root, "Solo", ["list"]); + const hub = new ProjectHub(discoverProjects([root])); + const ctx = singleProjectContext(hub.workspace()); + + expect(ctx.exists()).toBe(true); + expect(ctx.projects).toHaveLength(1); + expect(ctx.listEndpoints().map((e) => e.endpoint.id)).toEqual(["orders.list"]); + expect(ctx.resolveEndpoint("orders.list").endpointId).toBe("orders.list"); + }); +}); diff --git a/packages/core/src/projects.ts b/packages/core/src/projects.ts new file mode 100644 index 0000000..475a16b --- /dev/null +++ b/packages/core/src/projects.ts @@ -0,0 +1,412 @@ +/** + * Multi-project discovery. + * + * A folder can hold more than one Apilot project: the folder itself, and any + * nested folder that has an `.apilot/apilot.yaml`. Opening a monorepo root + * should show every project inside it — not just the root one — so Apilot + * discovers them all and addresses each by its own id. + * + * `ProjectHub` is the shared action layer for that: one `Workspace` per + * project, and one place that resolves an endpoint id to the right project. + */ + +import { existsSync, readFileSync, readdirSync, type Dirent } from "node:fs"; +import { basename, join, relative, resolve, sep } from "node:path"; +import { parse as parseYaml } from "yaml"; +import type { EndpointEntry } from "./types.js"; +import { Workspace } from "./workspace.js"; + +/** A folder that may hold an Apilot project. */ +export interface ProjectEntry { + /** Stable id: the path relative to the workspace folder ("." for the folder itself). */ + id: string; + /** Display name — the project name from apilot.yaml, else the folder name. */ + name: string; + /** Absolute path of the project folder. */ + root: string; + /** Absolute path of the workspace folder this project was found in. */ + folder: string; + /** Path relative to the workspace folder ("" for the folder itself). */ + relPath: string; + /** True when the folder has `.apilot/apilot.yaml`. */ + hasApilot: boolean; +} + +/** An endpoint together with the project it belongs to. */ +export interface ProjectEndpoint { + project: ProjectEntry; + endpoint: EndpointEntry; +} + +/** An endpoint resolved to its project and workspace. */ +export interface ResolvedEndpoint { + project: ProjectEntry; + ws: Workspace; + endpointId: string; +} + +/** + * What the tool layer needs to work across projects. `ProjectHub` implements + * it for many projects; `singleProjectContext` wraps one `Workspace`. + */ +export interface ToolContext { + readonly projects: ProjectEntry[]; + /** True when at least one project has an `.apilot` folder. */ + exists(): boolean; + /** Find an endpoint and the project that owns it. */ + resolveEndpoint(endpointId: string, projectId?: string): ResolvedEndpoint; + /** Every endpoint, across every project. */ + listEndpoints(projectId?: string): ProjectEndpoint[]; + /** The workspace of a project (or the default one). */ + workspace(projectId?: string): Workspace; + /** The workspace of a project, creating `.apilot/` there if it is missing. */ + ensureProject(projectId?: string): Workspace; + /** The workspace a command applies to when no project is given. */ + defaultWorkspace(): Workspace; + /** Create `.apilot/` in a project folder. */ + initProject(name: string, projectId?: string): { initialized: string }; +} + +// --------------------------------------------------------------------------- +// Discovery +// --------------------------------------------------------------------------- + +/** Directories never worth descending into. */ +const IGNORED_DIRS = new Set([ + "node_modules", "dist", "build", "out", "coverage", "vendor", "target", + "Pods", "DerivedData", "__pycache__", ".venv", ".gradle", ".dart_tool", + ".next", ".nuxt", ".cache", ".turbo", ".parcel-cache", ".terraform", +]); + +export interface DiscoverOptions { + /** How many levels below a workspace folder to look. Default 4. */ + maxDepth?: number; + /** Cap on directories scanned per folder, so a huge repo cannot stall startup. */ + maxDirs?: number; +} + +/** + * Find every folder under `folders` that can hold an Apilot project. + * + * The workspace folder itself is always included (even without `.apilot`) so + * the user can still initialize it. Nested folders are included only when + * they actually have a project, which keeps the list short in big repos. + */ +export function discoverProjects(folders: string[], opts: DiscoverOptions = {}): ProjectEntry[] { + const maxDepth = opts.maxDepth ?? 4; + const maxDirs = opts.maxDirs ?? 4000; + const roots = uniqueRoots(folders); + const multi = roots.length > 1; + const out: ProjectEntry[] = []; + const usedIds = new Set(); + /** A folder can be reached from more than one workspace folder — list it once. */ + const seenRoots = new Set(); + + for (const folder of roots) { + let scanned = 0; + + const walk = (dir: string, depth: number): void => { + if (depth > maxDepth || scanned >= maxDirs) return; + scanned++; + + const rel = dir === folder ? "" : relative(folder, dir).split(sep).join("/"); + // Only the workspace folder itself and folders that really hold a + // project are listed — the folders in between are just paths to them. + if ((dir === folder || existsSync(join(dir, ".apilot", "apilot.yaml"))) && !seenRoots.has(dir)) { + seenRoots.add(dir); + out.push(makeEntry(folder, dir, rel, multi, usedIds)); + } + + let entries: Dirent[]; + try { + // withFileTypes avoids a stat() per entry — this runs on every refresh. + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + for (const entry of entries) { + if (!entry.isDirectory()) continue; // symlinks are skipped too, so no cycles + if (entry.name.startsWith(".") || IGNORED_DIRS.has(entry.name)) continue; + walk(join(dir, entry.name), depth + 1); + } + }; + + walk(folder, 0); + } + + return out; +} + +function uniqueRoots(folders: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const f of folders) { + const norm = resolve(f); + if (seen.has(norm)) continue; + seen.add(norm); + out.push(norm); + } + return out; +} + +function makeEntry( + folder: string, + dir: string, + rel: string, + multi: boolean, + used: Set +): ProjectEntry { + const hasApilot = existsSync(join(dir, ".apilot", "apilot.yaml")); + const base = rel === "" ? basename(folder) : multi ? `${basename(folder)}/${rel}` : rel; + let id = base; + for (let n = 2; used.has(id); n++) id = `${base}-${n}`; + used.add(id); + return { + id, + name: (hasApilot ? projectName(dir) : undefined) ?? basename(dir), + root: dir, + folder, + relPath: rel, + hasApilot, + }; +} + +/** The `name` from `.apilot/apilot.yaml`, when it has one. */ +function projectName(dir: string): string | undefined { + try { + const raw = readFileSync(join(dir, ".apilot", "apilot.yaml"), "utf-8"); + const doc = parseYaml(raw) as { name?: unknown } | null; + return typeof doc?.name === "string" && doc.name.trim() ? doc.name.trim() : undefined; + } catch { + return undefined; + } +} + +// --------------------------------------------------------------------------- +// The hub +// --------------------------------------------------------------------------- + +export interface ProjectHubOptions { + /** Build the workspace for a project — the editor injects a per-project keychain provider. */ + makeWorkspace?: (root: string) => Workspace; + /** Request timeout in ms. */ + timeout?: number; + /** How many snapshots to keep per endpoint. */ + retention?: number; + /** How deep to look for nested projects. */ + discover?: DiscoverOptions; +} + +/** + * Every Apilot project inside one or more workspace folders, with a + * `Workspace` for each. This is what the panel, the CLI, and the MCP server + * all talk to. + */ +export class ProjectHub implements ToolContext { + private _projects: ProjectEntry[]; + private readonly workspaces = new Map(); + private readonly make: (root: string) => Workspace; + + constructor(projects: ProjectEntry[], opts: ProjectHubOptions = {}) { + this._projects = projects; + this.make = + opts.makeWorkspace ?? + ((root) => new Workspace(root, { timeout: opts.timeout, retention: opts.retention })); + } + + /** Every project in the workspace. */ + get projects(): ProjectEntry[] { + return this._projects; + } + + /** + * Replace the discovered projects (after a re-scan) while keeping the + * workspaces already built for them. + */ + setProjects(projects: ProjectEntry[]): void { + this._projects = projects; + } + + /** A hub for one folder and everything under it. */ + static forRoot(root: string, opts: ProjectHubOptions = {}): ProjectHub { + return new ProjectHub(discoverProjects([root], opts.discover), opts); + } + + /** True when at least one project has an `.apilot` folder. */ + exists(): boolean { + return this.projects.some((p) => p.hasApilot); + } + + /** The project a command applies to when none is named. */ + defaultProject(): ProjectEntry { + return this.projects.find((p) => p.hasApilot) ?? this.projects[0]!; + } + + /** Look a project up by id, relative path, or name. */ + project(id?: string): ProjectEntry { + if (!id) return this.defaultProject(); + const hit = this.findProject(id); + if (!hit) { + throw new Error( + `Project "${id}" not found. Available projects: ${this.projects.map((p) => p.id).join(", ") || "none"}.` + ); + } + return hit; + } + + private findProject(id: string): ProjectEntry | undefined { + const norm = id.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, ""); + const byId = this.projects.find((p) => p.id === id); + if (byId) return byId; + const byRel = this.projects.find((p) => p.relPath === norm); + if (byRel) return byRel; + const byName = this.projects.find((p) => p.name === id); + if (byName) return byName; + const suffix = this.projects.filter((p) => p.id.endsWith(`/${norm}`)); + return suffix.length === 1 ? suffix[0] : undefined; + } + + /** The workspace for a project (created on first use). */ + workspace(projectId?: string): Workspace { + const p = this.project(projectId); + let ws = this.workspaces.get(p.id); + if (!ws) { + ws = this.make(p.root); + this.workspaces.set(p.id, ws); + } + return ws; + } + + defaultWorkspace(): Workspace { + return this.workspace(); + } + + /** + * The workspace of a project, creating `.apilot/` there when the folder has + * no project yet — so adding an endpoint to a fresh folder just works. + */ + ensureProject(projectId?: string): Workspace { + const project = this.project(projectId); + const ws = this.workspace(project.id); + if (!project.hasApilot) { + ws.init(project.name); + project.hasApilot = true; + } + return ws; + } + + /** + * Find an endpoint. `endpointId` may be qualified as `project::endpoint`. + * Without a project the search covers every project, and an id that exists + * in more than one is reported instead of guessed. + */ + resolveEndpoint(endpointId: string, projectId?: string): ResolvedEndpoint { + let pid = projectId; + let eid = endpointId; + const sepIndex = endpointId.indexOf("::"); + if (sepIndex > 0) { + pid = endpointId.slice(0, sepIndex); + eid = endpointId.slice(sepIndex + 2); + } + + if (pid) { + const project = this.project(pid); + const ws = this.workspace(project.id); + const { entry } = ws.endpoint(eid); + return { project, ws, endpointId: entry.id }; + } + + const found: ResolvedEndpoint[] = []; + for (const project of this.projects) { + if (!project.hasApilot) continue; + const ws = this.workspace(project.id); + try { + const { entry } = ws.endpoint(eid); + found.push({ project, ws, endpointId: entry.id }); + } catch { + // not in this project + } + } + if (found.length === 1) return found[0]!; + if (found.length > 1) { + throw new Error( + `Endpoint "${eid}" exists in more than one project (${found.map((f) => f.project.id).join(", ")}). ` + + `Pass project: "" to choose one.` + ); + } + throw new Error(`Endpoint "${eid}" not found. Use list_endpoints to see available ids.`); + } + + listEndpoints(projectId?: string): ProjectEndpoint[] { + const list = projectId ? [this.project(projectId)] : this.projects.filter((p) => p.hasApilot); + const out: ProjectEndpoint[] = []; + for (const project of list) { + const ws = this.workspace(project.id); + for (const endpoint of ws.registry().endpoints) out.push({ project, endpoint }); + } + return out; + } + + /** Create `.apilot/` in a project folder (the first one without it, by default). */ + initProject(name: string, projectId?: string): { initialized: string } { + const project = projectId + ? this.project(projectId) + : this.projects.find((p) => !p.hasApilot) ?? this.defaultProject(); + const ws = this.workspace(project.id); + ws.init(name); + project.hasApilot = true; + return { initialized: ws.apilotDir }; + } +} + +// --------------------------------------------------------------------------- +// One project, wrapped as a context +// --------------------------------------------------------------------------- + +/** + * Present a single `Workspace` through the same interface a hub uses, so the + * tool layer works with either. + */ +export function singleProjectContext(ws: Workspace): ToolContext { + const project = (): ProjectEntry => ({ + id: basename(ws.root), + name: ws.project?.name ?? basename(ws.root), + root: ws.root, + folder: ws.root, + relPath: "", + hasApilot: ws.exists(), + }); + return { + get projects() { + return [project()]; + }, + exists: () => ws.exists(), + resolveEndpoint: (endpointId, projectId) => { + if (projectId && projectId !== project().id && projectId !== project().name) { + throw new Error(`Project "${projectId}" not found. Available projects: ${project().id}.`); + } + const { entry } = ws.endpoint(endpointId); + return { project: project(), ws, endpointId: entry.id }; + }, + listEndpoints: () => + ws.registry().endpoints.map((endpoint) => ({ project: project(), endpoint })), + workspace: (projectId) => { + if (projectId && projectId !== project().id && projectId !== project().name) { + throw new Error(`Project "${projectId}" not found. Available projects: ${project().id}.`); + } + return ws; + }, + ensureProject: () => ws, + defaultWorkspace: () => ws, + initProject: (name) => { + ws.init(name); + return { initialized: ws.apilotDir }; + }, + }; +} + +/** True when the value already speaks the `ToolContext` interface. */ +export function isToolContext(value: unknown): value is ToolContext { + return !!value && typeof (value as ToolContext).resolveEndpoint === "function"; +} diff --git a/packages/core/src/workspace.ts b/packages/core/src/workspace.ts index 33fe4a7..6754ad8 100644 --- a/packages/core/src/workspace.ts +++ b/packages/core/src/workspace.ts @@ -9,7 +9,7 @@ */ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; -import { dirname, join, relative } from "node:path"; +import { dirname, join, relative, sep } from "node:path"; import { parse as parseYaml, stringify as toYaml } from "yaml"; import type { ApilotProject, @@ -479,7 +479,7 @@ export class Workspace { scanned++; readFileSync(full, "utf-8").split("\n").forEach((text, i) => { for (const p of patterns) { - if (p.re.test(text)) matches.push({ file: relative(this.root, full), line: i + 1, field: p.field, text: text.trim().slice(0, 200) }); + if (p.re.test(text)) matches.push({ file: relative(this.root, full).split(sep).join("/"), line: i + 1, field: p.field, text: text.trim().slice(0, 200) }); } }); } diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 47480d7..ce42f41 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -5,6 +5,10 @@ * through the same Workspace + tool table the AI uses, so the AI can do * whatever the user can (except see or enter secret values). * + * A workspace can hold several Apilot projects — the folder itself, and any + * nested folder with an `.apilot/apilot.yaml`. All of them are discovered and + * shown, each addressed by its own project id. + * * On open, with no "connect" step: * - AI skills are written to the project (.cursor/skills, .claude/skills, Copilot instructions) * - the MCP server is registered through the editor's API @@ -17,14 +21,45 @@ import * as http from "node:http"; import * as crypto from "node:crypto"; import * as fs from "node:fs"; import * as path from "node:path"; -import { Workspace, type DiffResult, type RunOutcome, type SecretProvider } from "@apilot/core"; +import { + ProjectHub, + Workspace, + discoverProjects, + type DiffResult, + type ProjectEntry, + type RunOutcome, + type SecretProvider, +} from "@apilot/core"; import { callTool } from "@apilot/mcp/tools"; import { aiFiles, writeAiFiles, MARKER } from "@apilot/skill"; let ext: ApilotExtension | undefined; -export function activate(ctx: vscode.ExtensionContext): void { +/** A row in the sidebar tree. */ +export interface TreeRow { + kind: "project" | "collection" | "endpoint"; + label: string; + id?: string; + project?: string; +} + +/** What the extension exposes to other extensions and to its own tests. */ +export interface ApilotApi { + /** Every Apilot project found in the workspace. */ + projects(): { id: string; name: string; root: string; hasApilot: boolean }[]; + /** The rows the sidebar shows at the top level. */ + topLevel(): TreeRow[]; +} + +export function activate(ctx: vscode.ExtensionContext): ApilotApi { ext = new ApilotExtension(ctx); + return { + projects: () => { + ext!.rediscover(); + return ext!.hub.projects.map((p) => ({ id: p.id, name: p.name, root: p.root, hasApilot: p.hasApilot })); + }, + topLevel: () => ext!.topLevel(), + }; } export function deactivate(): void { @@ -58,19 +93,31 @@ class KeychainSecrets implements SecretProvider { } } +interface Health { + status?: number; + state: "none" | "passed" | "failed" | "breaking" | "changed"; +} + class ApilotExtension implements vscode.Disposable { - readonly root: string | undefined; - readonly ws: Workspace | undefined; + /** Every Apilot project found inside the workspace folders. */ + readonly hub: ProjectHub; private panel: vscode.WebviewPanel | undefined; private bridge: http.Server | undefined; private mcpRegistration: vscode.Disposable | undefined; private readonly tree = new EndpointTree(this); private readonly disposables: vscode.Disposable[] = []; private refreshTimer: NodeJS.Timeout | undefined; + /** When the project list was last re-scanned (see `rediscover`). */ + private lastScan = 0; + private watchers: vscode.Disposable[] = []; constructor(private readonly ctx: vscode.ExtensionContext) { - this.root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - this.ws = this.root ? new Workspace(this.root, { secrets: new KeychainSecrets(ctx.secrets, this.root), timeout: this.config("timeout") }) : undefined; + this.hub = new ProjectHub([], { + makeWorkspace: (root) => + new Workspace(root, { secrets: new KeychainSecrets(ctx.secrets, root), timeout: this.config("timeout") }), + }); + this.rediscover(); + this.watchProjects(); const treeView = vscode.window.createTreeView("apilot.endpoints", { treeDataProvider: this.tree, showCollapseAll: true }); const cmd = (id: string, fn: (...a: any[]) => unknown) => vscode.commands.registerCommand(id, async (...a: any[]) => { @@ -83,31 +130,35 @@ class ApilotExtension implements vscode.Disposable { this.disposables.push( treeView, - cmd("apilot.open", (id?: string) => this.openPanel(typeof id === "string" ? { endpointId: id } : undefined)), - cmd("apilot.init", () => this.init()), + cmd("apilot.open", (arg?: string | EndpointItem) => { + if (arg && typeof arg === "object") return this.openPanel({ endpointId: arg.endpointId, project: arg.project.id }); + if (typeof arg === "string") return this.openPanel({ endpointId: arg }); + return this.openPanel(); + }), + cmd("apilot.init", (projectId?: string) => this.init(projectId)), cmd("apilot.newEndpoint", () => this.openPanel({ view: "new" })), cmd("apilot.import", () => this.openPanel({ view: "import" })), cmd("apilot.environments", () => this.openPanel({ view: "environments" })), cmd("apilot.runAll", () => this.openPanel({ view: "runAll", autorun: true })), - cmd("apilot.runEndpoint", (item?: EndpointItem | string) => this.runFromTree(typeof item === "string" ? item : item?.endpointId)), - cmd("apilot.openFile", (item?: EndpointItem) => item?.file && this.openFile(item.file)), - cmd("apilot.refresh", () => this.refresh()), + cmd("apilot.runEndpoint", (item?: EndpointItem | string) => this.runFromTree(item)), + cmd("apilot.openFile", (item?: EndpointItem) => item && this.openFile(item.file, undefined, item.project.id)), + cmd("apilot.refresh", () => { + this.rediscover(true); + this.refresh(); + }), cmd("apilot.setupAi", () => this.setupAi(true)), cmd("apilot.removeAi", () => this.removeAi()), vscode.workspace.onDidSaveTextDocument((doc) => this.onSave(doc)), + vscode.workspace.onDidChangeWorkspaceFolders(() => { + this.rediscover(true); + this.watchProjects(); + this.refresh(); + }), vscode.window.registerWebviewPanelSerializer("apilot.panel", { deserializeWebviewPanel: async (panel) => this.attachPanel(panel), }) ); - if (this.root) { - const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(this.root, ".apilot/**")); - const onChange = (uri: vscode.Uri) => { - if (!uri.fsPath.includes(`${path.sep}.local${path.sep}`)) this.scheduleRefresh(); - }; - this.disposables.push(watcher, watcher.onDidCreate(onChange), watcher.onDidChange(onChange), watcher.onDidDelete(onChange)); - } - this.onProjectReady(); } @@ -115,28 +166,115 @@ class ApilotExtension implements vscode.Disposable { return vscode.workspace.getConfiguration("apilot").get(key); } + /** Every workspace folder open in the editor. */ + get folders(): string[] { + return (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); + } + get hasProject(): boolean { - return !!this.ws?.exists(); + return this.hub.exists(); + } + + /** The folder the bridge and the skills live in. */ + get bridgeRoot(): string | undefined { + return this.folders[0]; + } + + /** + * Re-scan the workspace folders for Apilot projects. + * + * The file watcher below is the fast path, but it is not guaranteed — + * on CI it can fall back to polling, or silently stop if the OS runs out + * of watch handles. Every surface that reads the project list therefore + * re-scans on demand, throttled so a burst of changes costs one walk. + */ + rediscover(force = false): void { + const now = Date.now(); + if (!force && now - this.lastScan < 500) return; + this.lastScan = now; + this.hub.setProjects(discoverProjects(this.folders)); + } + + /** + * Watch every `.apilot` folder in the workspace. A folder appearing or + * disappearing changes the project list, so it triggers a re-scan. + */ + private watchProjects(): void { + for (const w of this.watchers) w.dispose(); + this.watchers = []; + for (const folder of this.folders) { + const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(vscode.Uri.file(folder), "**/.apilot/**")); + const onChange = (uri: vscode.Uri) => { + if (uri.fsPath.includes(`${path.sep}.local${path.sep}`)) return; + if (uri.fsPath.endsWith(`${path.sep}apilot.yaml`)) this.rediscover(true); + this.scheduleRefresh(); + }; + watcher.onDidCreate(onChange); + watcher.onDidChange(onChange); + watcher.onDidDelete(onChange); + this.watchers.push(watcher); + } } - get env(): string | undefined { - return this.ctx.workspaceState.get("apilot.env"); + /** The project the panel is showing. */ + activeProject(): ProjectEntry { + const stored = this.ctx.workspaceState.get("apilot.activeProject"); + if (stored) { + try { + return this.hub.project(stored); + } catch { + /* the folder went away — fall back */ + } + } + return this.hub.defaultProject(); + } + + /** The environment selected for a project. */ + envFor(projectId: string): string | undefined { + const map = this.ctx.workspaceState.get>("apilot.env") ?? {}; + const stored = map[projectId]; + if (stored) { + try { + const names = this.hub.workspace(projectId).registry().environments.map((e) => e.name); + if (names.includes(stored)) return stored; + } catch { + /* fall through */ + } + } + const reg = this.hub.workspace(projectId).registry(); + return reg.project.defaultEnvironment ?? reg.environments[0]?.name; + } + + private async setEnv(projectId: string, env: string): Promise { + const map = this.ctx.workspaceState.get>("apilot.env") ?? {}; + map[projectId] = env; + await this.ctx.workspaceState.update("apilot.env", map); } /** Called on activation and after `init`: everything that needs a project. */ private onProjectReady(): void { vscode.commands.executeCommand("setContext", "apilot.hasProject", this.hasProject); - if (!this.hasProject || !this.root) return; - ensureLocalIgnored(this.root); + if (!this.hasProject || !this.bridgeRoot) return; + ensureLocalIgnored(this.bridgeRoot); + for (const p of this.hub.projects) if (p.hasApilot) ensureLocalIgnored(p.root); this.startBridge().catch((err) => console.error("[apilot] bridge failed", err)); if (this.config("ai.autoSetup") !== false && !this.ctx.workspaceState.get("apilot.aiRemoved")) this.setupAi(false); } - private async init(): Promise { - if (!this.ws) throw new Error("Open a folder first."); - const name = await vscode.window.showInputBox({ title: "Apilot — new project", prompt: "Project name", value: path.basename(this.root!) }); + private async init(projectId?: string): Promise { + if (!this.folders.length) throw new Error("Open a folder first."); + const project = projectId + ? this.hub.project(projectId) + : this.hub.projects.find((p) => !p.hasApilot) ?? this.hub.defaultProject(); + const name = await vscode.window.showInputBox({ + title: "Apilot — new project", + prompt: `Initialize Apilot in "${project.root}"`, + value: path.basename(project.root), + }); if (name === undefined) return; - this.ws.init(name || "my-api"); + this.hub.initProject(name || "my-api", project.id); + this.rediscover(true); + await this.ctx.workspaceState.update("apilot.activeProject", project.id); this.onProjectReady(); this.refresh(); this.openPanel(); @@ -147,8 +285,9 @@ class ApilotExtension implements vscode.Disposable { // ------------------------------------------------------------------------- private setupAi(manual: boolean): void { - if (!this.root) return; - const written = writeAiFiles(this.root); + const root = this.bridgeRoot; + if (!root) return; + const written = writeAiFiles(root); const how = this.registerMcp(); if (manual) this.ctx.workspaceState.update("apilot.aiRemoved", undefined); if (manual || (written.length && !this.ctx.workspaceState.get("apilot.aiNotified"))) { @@ -162,9 +301,10 @@ class ApilotExtension implements vscode.Disposable { } private removeAi(): void { - if (!this.root) return; + const root = this.bridgeRoot; + if (!root) return; for (const f of aiFiles()) { - const full = path.join(this.root, f.path); + const full = path.join(root, f.path); try { if (fs.readFileSync(full, "utf-8").includes(MARKER)) fs.rmSync(full); } catch { @@ -178,11 +318,12 @@ class ApilotExtension implements vscode.Disposable { } private registerMcp(): "Cursor" | "VS Code" | "none" { - if (this.mcpRegistration || !this.root) return this.mcpRegistration ? (vscode.env.appName.includes("Cursor") ? "Cursor" : "VS Code") : "none"; + const root = this.bridgeRoot; + if (this.mcpRegistration || !root) return this.mcpRegistration ? (vscode.env.appName.includes("Cursor") ? "Cursor" : "VS Code") : "none"; const server = { command: process.execPath, args: [this.ctx.asAbsolutePath(path.join("dist", "mcp.mjs"))], - env: { ELECTRON_RUN_AS_NODE: "1", APILOT_PROJECT_ROOT: this.root }, + env: { ELECTRON_RUN_AS_NODE: "1", APILOT_PROJECT_ROOT: root }, }; const api = vscode as any; try { @@ -213,7 +354,8 @@ class ApilotExtension implements vscode.Disposable { // ------------------------------------------------------------------------- private async startBridge(): Promise { - if (this.bridge || !this.root) return; + const root = this.bridgeRoot; + if (this.bridge || !root) return; const token = crypto.randomBytes(32).toString("hex"); const server = http.createServer((req, res) => { const reply = (status: number, body: unknown) => { @@ -242,16 +384,20 @@ class ApilotExtension implements vscode.Disposable { }); await new Promise((resolve, reject) => server.once("error", reject).listen(0, "127.0.0.1", resolve)); this.bridge = server; - const file = path.join(this.root, ".apilot", ".local", "bridge.json"); + const file = path.join(root, ".apilot", ".local", "bridge.json"); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify({ port: (server.address() as { port: number }).port, token, pid: process.pid }), { mode: 0o600 }); } /** Run a tool (from the AI via the bridge, or the panel) and keep the GUI in sync. */ private async tool(name: string, args: Record): Promise { - const ws = this.requireWs(); - if (name.startsWith("run_") && !args.env && this.env) args = { ...args, env: this.env }; - const result = await callTool(ws, name, args); + if (name.startsWith("run_") && !args.env) { + const projectId = args.project as string | undefined; + const project = projectId ? this.hub.project(projectId) : this.hub.defaultProject(); + const env = this.envFor(project.id); + if (env) args = { ...args, env }; + } + const result = await callTool(this.hub, name, args); if (name === "run_endpoint") { const r = result as { endpointId: string; diffAgainstBaseline?: { breaking: boolean; summary: DiffResult["summary"] } }; if (r.diffAgainstBaseline?.breaking) this.alertBreaking(r.endpointId, r.diffAgainstBaseline.summary.breaking); @@ -272,17 +418,21 @@ class ApilotExtension implements vscode.Disposable { vscode.window.showWarningMessage(msg, "Show Changes", "Accept as Baseline").then((pick) => { if (pick === "Show Changes") this.openPanel({ endpointId, tab: "changes" }); if (pick === "Accept as Baseline") { - this.requireWs().setBaseline(endpointId); + const { ws, endpointId: eid } = this.hub.resolveEndpoint(endpointId); + ws.setBaseline(eid); this.scheduleRefresh(); } }); } - private async runFromTree(endpointId?: string): Promise { + private async runFromTree(item?: EndpointItem | string): Promise { + const endpointId = typeof item === "string" ? item : item?.endpointId; + const projectId = typeof item === "string" ? undefined : item?.project.id; if (!endpointId) return; - const r = await vscode.window.withProgress({ location: vscode.ProgressLocation.Window, title: `Apilot: running ${endpointId}` }, () => - this.tool("run_endpoint", { endpointId }) - ) as { status: number; timeMs: number; passed: boolean; diffAgainstBaseline?: { breaking: boolean } }; + const r = (await vscode.window.withProgress( + { location: vscode.ProgressLocation.Window, title: `Apilot: running ${endpointId}` }, + () => this.tool("run_endpoint", projectId ? { endpointId, project: projectId } : { endpointId }) + )) as { status: number; timeMs: number; passed: boolean; diffAgainstBaseline?: { breaking: boolean } }; if (!r.diffAgainstBaseline?.breaking) { vscode.window.setStatusBarMessage(`$(${r.passed ? "pass" : "error"}) ${endpointId} → ${r.status} (${r.timeMs} ms)`, 5000); } @@ -330,49 +480,81 @@ class ApilotExtension implements vscode.Disposable { /** Requests from the panel. Tool names map 1:1 to the AI's MCP tools. */ private async handle(method: string, a: any): Promise { if (method.startsWith("tool:")) return this.tool(method.slice(5), a); - const ws = this.requireWs(method === "state" || method === "init"); + this.requireWs(method === "state" || method === "init"); switch (method) { case "state": return this.state(); case "init": - return this.init(); + return this.init(a.project); case "setEnv": - await this.ctx.workspaceState.update("apilot.env", a.env); + await this.setEnv(a.project ?? this.activeProject().id, a.env); this.tree.refresh(); return null; case "endpoint": { - const { entry, definition } = ws.endpoint(a.id); - const snapshots = ws.snapshots(a.id); - return { id: a.id, file: entry.file, collectionId: entry.collectionId, definition, snapshots, revisions: ws.revisions(a.id), latest: ws.store.latest(a.id) ?? null }; + const { project, ws: workspace, endpointId } = this.hub.resolveEndpoint(a.id, a.project); + const { entry, definition } = workspace.endpoint(endpointId); + return { + id: endpointId, + project: project.id, + file: entry.file, + collectionId: entry.collectionId, + definition, + snapshots: workspace.snapshots(endpointId), + revisions: workspace.revisions(endpointId), + latest: workspace.store.latest(endpointId) ?? null, + }; } case "save": { - const r = ws.saveEndpoint(a); + const { project: _ignored, ...rest } = a; + const r = rest.id + ? (() => { + const { ws: workspace, endpointId } = this.hub.resolveEndpoint(rest.id, a.project); + return workspace.saveEndpoint({ ...rest, id: endpointId }); + })() + : this.hub.ensureProject(a.project).saveEndpoint(rest); this.scheduleRefresh(); return r; } case "run": { - const r: RunOutcome = await ws.run(a.id, a.env ?? this.env); - if (r.diff?.breaking) this.alertBreaking(a.id, r.diff.summary.breaking); + const { project, ws: workspace, endpointId } = this.hub.resolveEndpoint(a.id, a.project); + const r: RunOutcome = await workspace.run(endpointId, a.env ?? this.envFor(project.id)); + if (r.diff?.breaking) this.alertBreaking(endpointId, r.diff.summary.breaking); this.scheduleRefresh(); return r; } case "snapshotPair": { - const diff = ws.diff(a.id, a.from, a.to); - return { diff, from: ws.snapshot(a.id, diff.fromId), to: ws.snapshot(a.id, diff.toId), impact: ws.impact(a.id, diff) }; + const { project, ws: workspace, endpointId } = this.hub.resolveEndpoint(a.id, a.project); + const diff = workspace.diff(endpointId, a.from, a.to); + return { + diff, + from: workspace.snapshot(endpointId, diff.fromId), + to: workspace.snapshot(endpointId, diff.toId), + impact: workspace.impact(endpointId, diff), + project: project.id, + }; + } + case "snapshot": { + const { ws: workspace, endpointId } = this.hub.resolveEndpoint(a.id, a.project); + return workspace.snapshot(endpointId, a.snapshotId); + } + case "schema": { + const { ws: workspace, endpointId } = this.hub.resolveEndpoint(a.id, a.project); + return workspace.schema(endpointId); } - case "snapshot": - return ws.snapshot(a.id, a.snapshotId); - case "schema": - return ws.schema(a.id); case "variables": { - const env = ws.envName(a.env ?? this.env); - return { env, variables: await ws.variables(env) }; + const project = a.project ? this.hub.project(a.project) : this.activeProject(); + const env = a.env ?? this.envFor(project.id); + const name = env ?? this.hub.workspace(project.id).envName(); + return { env: name, project: project.id, variables: await this.hub.workspace(project.id).variables(name) }; } - case "setSecret": - await ws.setVariable(ws.envName(a.env), a.name, "secret", a.value); + case "setSecret": { + const project = a.project ? this.hub.project(a.project) : this.activeProject(); + const env = a.env ?? this.envFor(project.id) ?? this.hub.workspace(project.id).envName(); + await this.hub.workspace(project.id).setVariable(env, a.name, "secret", a.value); return null; + } case "openFile": - return this.openFile(a.file, a.line); + return this.openFile(a.file, a.line, a.project); case "nativeDiff": return this.nativeDiff(a); case "setupAi": @@ -385,25 +567,47 @@ class ApilotExtension implements vscode.Disposable { } private state() { - const ws = this.ws; - if (!ws || !this.hasProject) return { hasProject: false, hasFolder: !!ws }; - const reg = ws.registry(); - const env = this.env && reg.environments.some((e) => e.name === this.env) ? this.env : reg.project.defaultEnvironment ?? reg.environments[0]?.name; + this.rediscover(); + const projects = this.hub.projects.map((p) => ({ + id: p.id, + name: p.name, + root: p.root, + relPath: p.relPath, + hasApilot: p.hasApilot, + })); + if (!this.hasProject) return { hasProject: false, hasFolder: this.folders.length > 0, projects }; + + const active = this.activeProject(); + const reg = this.hub.workspace(active.id).registry(); + const endpoints = this.hub.listEndpoints().map(({ project, endpoint }) => ({ + ...endpoint, + project: project.id, + health: this.health(project.id, endpoint.id), + })); + const projectEnvs: Record = {}; + for (const p of this.hub.projects) { + if (!p.hasApilot) continue; + const names = this.hub.workspace(p.id).registry().environments.map((e) => e.name); + projectEnvs[p.id] = { env: this.envFor(p.id), environments: names }; + } return { hasProject: true, hasFolder: true, + projects, + activeProject: active.id, project: reg.project, - env, + env: this.envFor(active.id), environments: reg.environments.map((e) => e.name), + projectEnvs, collections: reg.collections, - endpoints: reg.endpoints.map((e) => ({ ...e, health: this.health(e.id) })), + endpoints, }; } /** Status dot for an endpoint: last run result and whether it drifted from the baseline. */ - health(id: string): { status?: number; state: "none" | "passed" | "failed" | "breaking" | "changed" } { - const ws = this.requireWs(); + health(projectId: string, id: string): Health { try { + const ws = this.hub.workspace(projectId); const latest = ws.store.list(id)[0]; if (!latest) return { state: "none" }; if (!latest.passed) return { status: latest.status, state: "failed" }; @@ -415,8 +619,44 @@ class ApilotExtension implements vscode.Disposable { } } - private async openFile(file: string, line?: number): Promise { - const uri = vscode.Uri.file(path.isAbsolute(file) ? file : path.join(this.root!, file)); + /** + * What the sidebar shows at the top level. With one project that is its + * collections; with several it is one row per project. + */ + topLevel(): TreeRow[] { + const projects = this.hub.projects; + if (!projects.length) return []; + if (projects.length === 1) { + return this.childrenOf(projects[0]!).map((item) => + item instanceof CollectionItem + ? { kind: "collection", label: String(item.label), id: item.collectionId, project: projects[0]!.id } + : { kind: "endpoint", label: String(item.label), id: (item as EndpointItem).endpointId, project: projects[0]!.id } + ); + } + return projects.map((p) => ({ kind: "project", label: p.name, id: p.id })); + } + + /** Collections and endpoints of one project, for the sidebar tree. */ + childrenOf(project: ProjectEntry, parent?: CollectionItem): vscode.TreeItem[] { + if (!project.hasApilot) return []; + const reg = this.hub.workspace(project.id).registry(); + const parentId = parent?.collectionId ?? ""; + const collections = reg.collections + .filter((c) => + parentId + ? c.id.startsWith(`${parentId}/`) && !c.id.slice(parentId.length + 1).includes("/") + : !c.id.includes("/") + ) + .map((c) => new CollectionItem(project, c.id, c.name)); + const endpoints = reg.endpoints + .filter((e) => e.collectionId === parentId) + .map((e) => new EndpointItem(project, e.id, e.file, e.name, e.method, e.url, this.health(project.id, e.id))); + return [...collections, ...endpoints]; + } + + private async openFile(file: string, line?: number, projectId?: string): Promise { + const base = projectId ? this.hub.project(projectId).root : this.hub.defaultProject().root; + const uri = vscode.Uri.file(path.isAbsolute(file) ? file : path.join(base, file)); const doc = await vscode.window.showTextDocument(uri, { viewColumn: vscode.ViewColumn.Beside, preview: true }); if (line) { const pos = new vscode.Position(Math.max(0, line - 1), 0); @@ -426,8 +666,8 @@ class ApilotExtension implements vscode.Disposable { } /** Open two snapshots or two revisions in the editor's built-in diff view. */ - private async nativeDiff(a: { kind: "snapshot" | "revision"; id: string; from: string | number; to: string | number }): Promise { - const ws = this.requireWs(); + private async nativeDiff(a: { kind: "snapshot" | "revision"; id: string; from: string | number; to: string | number; project?: string }): Promise { + const { ws } = this.hub.resolveEndpoint(a.id, a.project); const file = (x: string | number) => a.kind === "snapshot" ? ws.store.path(a.id, String(x)) @@ -466,15 +706,18 @@ class ApilotExtension implements vscode.Disposable { // ------------------------------------------------------------------------- private onSave(doc: vscode.TextDocument): void { - if (!this.ws || !this.root) return; - const rel = path.relative(this.root, doc.uri.fsPath).split(path.sep).join("/"); - if (!rel.startsWith(".apilot/collections/") || !rel.endsWith(".yaml") || rel.endsWith("_folder.yaml")) return; - const ep = this.ws.registry().endpoints.find((e) => e.file === rel); - if (!ep) return; - try { - this.ws.recordRevision(ep.id); - } catch (err) { - vscode.window.showErrorMessage(`Apilot: ${rel} is not a valid endpoint — ${err instanceof Error ? err.message : String(err)}`); + for (const p of this.hub.projects) { + if (!p.hasApilot) continue; + const rel = path.relative(p.root, doc.uri.fsPath).split(path.sep).join("/"); + if (!rel.startsWith(".apilot/collections/") || !rel.endsWith(".yaml") || rel.endsWith("_folder.yaml")) continue; + const ws = this.hub.workspace(p.id); + const ep = ws.registry().endpoints.find((e) => e.file === rel); + if (!ep) continue; + try { + ws.recordRevision(ep.id); + } catch (err) { + vscode.window.showErrorMessage(`Apilot: ${rel} is not a valid endpoint — ${err instanceof Error ? err.message : String(err)}`); + } } } @@ -484,24 +727,26 @@ class ApilotExtension implements vscode.Disposable { } refresh(): void { + this.rediscover(); vscode.commands.executeCommand("setContext", "apilot.hasProject", this.hasProject); this.tree.refresh(); this.post({ type: "changed" }); } requireWs(allowNoProject = false): Workspace { - if (!this.ws) throw new Error("Open a folder to use Apilot."); + if (!this.folders.length) throw new Error("Open a folder to use Apilot."); if (!allowNoProject && !this.hasProject) throw new Error("No Apilot project in this folder yet. Run “Apilot: Initialize Project”."); - return this.ws; + return this.hub.defaultWorkspace(); } dispose(): void { this.bridge?.close(); - const bridgeFile = this.root && path.join(this.root, ".apilot", ".local", "bridge.json"); + const bridgeFile = this.bridgeRoot && path.join(this.bridgeRoot, ".apilot", ".local", "bridge.json"); try { if (bridgeFile && JSON.parse(fs.readFileSync(bridgeFile, "utf8")).pid === process.pid) fs.rmSync(bridgeFile); } catch {} this.mcpRegistration?.dispose(); + for (const w of this.watchers) w.dispose(); this.disposables.forEach((d) => d.dispose()); } } @@ -513,11 +758,29 @@ function ensureLocalIgnored(root: string): void { } // --------------------------------------------------------------------------- -// Sidebar tree: collections → endpoints, with status icons +// Sidebar tree: projects → collections → endpoints, with status icons // --------------------------------------------------------------------------- +class ProjectItem extends vscode.TreeItem { + constructor(readonly project: ProjectEntry) { + super( + project.name, + project.hasApilot ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None + ); + this.contextValue = "project"; + this.iconPath = new vscode.ThemeIcon(project.hasApilot ? "folder-library" : "folder"); + this.description = project.hasApilot ? project.relPath : "not initialized"; + this.tooltip = new vscode.MarkdownString( + `${project.hasApilot ? "**Apilot project**" : "No Apilot project in this folder yet"}\n\n\`${project.root}\`` + ); + if (!project.hasApilot) { + this.command = { command: "apilot.init", title: "Initialize Apilot Project", arguments: [project.id] }; + } + } +} + class CollectionItem extends vscode.TreeItem { - constructor(readonly collectionId: string, label: string) { + constructor(readonly project: ProjectEntry, readonly collectionId: string, label: string) { super(label, vscode.TreeItemCollapsibleState.Expanded); this.contextValue = "collection"; this.iconPath = new vscode.ThemeIcon("folder"); @@ -525,15 +788,23 @@ class CollectionItem extends vscode.TreeItem { } class EndpointItem extends vscode.TreeItem { - constructor(readonly endpointId: string, readonly file: string, name: string, method: string, url: string, health: ReturnType) { + constructor( + readonly project: ProjectEntry, + readonly endpointId: string, + readonly file: string, + name: string, + method: string, + url: string, + health: Health + ) { super(name, vscode.TreeItemCollapsibleState.None); this.description = `${method}${health.status ? ` · ${health.status}` : ""}`; - this.tooltip = new vscode.MarkdownString(`**${method}** \`${url}\`\n\n${endpointId}${health.state === "breaking" ? "\n\n⚠ Breaking changes vs baseline" : ""}`); + this.tooltip = new vscode.MarkdownString(`**${method}** \`${url}\`\n\n${project.id} · ${endpointId}${health.state === "breaking" ? "\n\n⚠ Breaking changes vs baseline" : ""}`); this.contextValue = "endpoint"; const icons = { none: ["circle-outline", undefined], passed: ["pass", "testing.iconPassed"], failed: ["error", "testing.iconFailed"], breaking: ["warning", "list.warningForeground"], changed: ["diff", "charts.blue"] } as const; const [icon, color] = icons[health.state]; this.iconPath = new vscode.ThemeIcon(icon, color ? new vscode.ThemeColor(color) : undefined); - this.command = { command: "apilot.open", title: "Open", arguments: [endpointId] }; + this.command = { command: "apilot.open", title: "Open", arguments: [this] }; } } @@ -552,15 +823,18 @@ class EndpointTree implements vscode.TreeDataProvider { } getChildren(parent?: vscode.TreeItem): vscode.TreeItem[] { - if (!this.ext.hasProject) return []; // welcome view shows Init / Import buttons - const reg = this.ext.requireWs().registry(); - const parentId = parent instanceof CollectionItem ? parent.collectionId : ""; - const collections = reg.collections - .filter((c) => (parentId ? c.id.startsWith(`${parentId}/`) && !c.id.slice(parentId.length + 1).includes("/") : !c.id.includes("/"))) - .map((c) => new CollectionItem(c.id, c.name)); - const endpoints = reg.endpoints - .filter((e) => e.collectionId === parentId) - .map((e) => new EndpointItem(e.id, e.file, e.name, e.method, e.url, this.ext.health(e.id))); - return [...collections, ...endpoints]; + // Expanding or focusing the view re-scans, so a project added by hand + // shows up even if the file watcher missed it. + if (!parent) this.ext.rediscover(); + const projects = this.ext.hub.projects; + if (!projects.length) return []; // welcome view shows Init / Import buttons + // One project: show its collections directly, as before. + if (projects.length === 1) { + return this.ext.childrenOf(projects[0]!, parent instanceof CollectionItem ? parent : undefined); + } + if (!parent) return projects.map((p) => new ProjectItem(p)); + if (parent instanceof ProjectItem) return this.ext.childrenOf(parent.project, undefined); + if (parent instanceof CollectionItem) return this.ext.childrenOf(parent.project, parent); + return []; } } diff --git a/packages/extension/src/test/suite/index.test.ts b/packages/extension/src/test/suite/index.test.ts index 6cd1dee..2dc8ab9 100644 --- a/packages/extension/src/test/suite/index.test.ts +++ b/packages/extension/src/test/suite/index.test.ts @@ -3,6 +3,12 @@ import * as fs from "node:fs"; import * as path from "node:path"; import * as vscode from "vscode"; +/** The shape the extension exports from `activate()`. */ +interface ApilotApi { + projects(): { id: string; name: string; root: string; hasApilot: boolean }[]; + topLevel(): { kind: string; label: string; id?: string; project?: string }[]; +} + const root = () => vscode.workspace.workspaceFolders![0]!.uri.fsPath; async function waitFor(fn: () => T | false | undefined | "", ms = 10000): Promise { @@ -15,6 +21,22 @@ async function waitFor(fn: () => T | false | undefined | "", ms = 10000): Pro } } +async function api(): Promise { + return (await vscode.extensions.getExtension("Darkmintis.apilot")!.activate()) as unknown as ApilotApi; +} + +/** A second project nested inside the workspace folder. Idempotent. */ +function createNestedProject(): string { + const nested = path.join(root(), "services", "api"); + fs.mkdirSync(path.join(nested, ".apilot", "collections", "orders"), { recursive: true }); + fs.writeFileSync(path.join(nested, ".apilot", "apilot.yaml"), "name: Nested API\n"); + fs.writeFileSync( + path.join(nested, ".apilot", "collections", "orders", "list.yaml"), + 'name: List\nmethod: GET\nurl: "{{baseUrl}}/orders"\n' + ); + return nested; +} + suite("Apilot extension", () => { suiteSetup(async () => { await vscode.extensions.getExtension("Darkmintis.apilot")!.activate(); @@ -39,6 +61,7 @@ suite("Apilot extension", () => { test("bridge runs tools for the MCP server and rejects bad tokens", async () => { const file = path.join(root(), ".apilot/.local/bridge.json"); const { port, token } = JSON.parse(await waitFor(() => fs.existsSync(file) && fs.readFileSync(file, "utf-8"))); + const call = (auth: string) => fetch(`http://127.0.0.1:${port}/call`, { method: "POST", headers: { Authorization: auth }, body: JSON.stringify({ name: "list_endpoints", args: {} }) }); @@ -58,4 +81,38 @@ suite("Apilot extension", () => { await vscode.commands.executeCommand("apilot.open", "orders.list"); await waitFor(() => vscode.window.tabGroups.all.flatMap((g) => g.tabs).find((t) => t.label === "Apilot")); }); + + test("finds Apilot projects nested inside the workspace folder", async () => { + const nested = createNestedProject(); + // Re-scan on demand instead of waiting for the OS file watcher: on CI the + // watcher may fall back to polling, or stop when watch handles run out. + await vscode.commands.executeCommand("apilot.refresh"); + + const a = await api(); + const projects = a.projects().filter((p) => p.root === nested); + assert.strictEqual(projects.length, 1, "nested project not discovered"); + assert.strictEqual(projects[0]!.hasApilot, true); + + // With more than one project, the sidebar lists one row per project. + const rows = a.topLevel(); + assert.ok(rows.some((r) => r.kind === "project" && r.id === "services/api"), "nested project missing from the tree"); + assert.ok(rows.some((r) => r.kind === "project" && r.id !== "services/api"), "root project missing from the tree"); + }); + + test("the AI sees every project through the bridge", async () => { + createNestedProject(); // idempotent — this test also runs on its own + await vscode.commands.executeCommand("apilot.refresh"); + + const file = path.join(root(), ".apilot/.local/bridge.json"); + const { port, token } = JSON.parse(fs.readFileSync(file, "utf-8")); + const res = (await (await fetch(`http://127.0.0.1:${port}/call`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify({ name: "list_endpoints", args: {} }), + })).json()) as { result: { projects: { id: string }[]; endpoints: { id: string; project: string }[] } }; + + assert.ok(res.result.projects.some((p) => p.id === "services/api"), "nested project missing from list_endpoints"); + const nested = res.result.endpoints.find((e) => e.project === "services/api" && e.id === "orders.list"); + assert.ok(nested, "nested endpoint missing from list_endpoints"); + }); }); diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index e15d41d..d550172 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -7,6 +7,9 @@ * extension, which resolves secrets from the OS keychain and keeps the GUI in * sync. Otherwise tools run here, with secrets from APILOT_SECRET_. * Either way, the AI only ever sees redacted data. + * + * A folder can hold several Apilot projects; all of them are discovered, and + * every endpoint tool takes an optional `project` to say which one it means. */ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; @@ -14,7 +17,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { existsSync, readFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; -import { Workspace } from "@apilot/core"; +import { ProjectHub } from "@apilot/core"; import { TOOLS, callTool } from "./tools.js"; function findRoot(): string { @@ -27,20 +30,32 @@ function findRoot(): string { } const root = findRoot(); -const ws = new Workspace(root); +const hub = ProjectHub.forRoot(root); interface Bridge { port: number; token: string; } -async function viaBridge(name: string, args: Record): Promise<{ ok: true; result: unknown } | { ok: false }> { - let bridge: Bridge; - try { - bridge = JSON.parse(readFileSync(join(root, ".apilot", ".local", "bridge.json"), "utf-8")) as Bridge; - } catch { - return { ok: false }; +/** Find the editor's bridge file — it may live in a parent of this project. */ +function findBridge(): Bridge | undefined { + let dir = root; + for (;;) { + try { + const file = join(dir, ".apilot", ".local", "bridge.json"); + if (existsSync(file)) return JSON.parse(readFileSync(file, "utf-8")) as Bridge; + } catch { + /* unreadable — keep looking */ + } + const parent = dirname(dir); + if (parent === dir) return undefined; + dir = parent; } +} + +async function viaBridge(name: string, args: Record): Promise<{ ok: true; result: unknown } | { ok: false }> { + const bridge = findBridge(); + if (!bridge) return { ok: false }; let res: Response; try { res = await fetch(`http://127.0.0.1:${bridge.port}/call`, { @@ -65,7 +80,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const args = (request.params.arguments ?? {}) as Record; try { const bridged = await viaBridge(name, args); - const result = bridged.ok ? bridged.result : await callTool(ws, name, args); + const result = bridged.ok ? bridged.result : await callTool(hub, name, args); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; } catch (err) { return { content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }], isError: true }; diff --git a/packages/mcp/src/tools.test.ts b/packages/mcp/src/tools.test.ts index 219bfce..de7abf5 100644 --- a/packages/mcp/src/tools.test.ts +++ b/packages/mcp/src/tools.test.ts @@ -1,6 +1,10 @@ -import { describe, it, expect, vi } from "vitest"; +import { afterEach, describe, it, expect, vi } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { stringify as toYaml } from "yaml"; import { callTool, TOOLS } from "./tools"; -import { schemaToJsonSchema } from "@apilot/core"; +import { ProjectHub, discoverProjects, schemaToJsonSchema } from "@apilot/core"; import type { Workspace } from "@apilot/core"; /** Minimal mock Workspace — each method is a vi.fn() so tests can assert calls @@ -465,3 +469,75 @@ describe("callTool — codegen & import", () => { expect(result.latestResponse.body.preview).toHaveLength(60_000); }); }); + +// --------------------------------------------------------------------------- +// Several projects in one workspace +// --------------------------------------------------------------------------- + +describe("callTool — multiple projects", () => { + const temps: string[] = []; + + /** A workspace root with two nested Apilot projects. */ + function makeHub() { + const root = mkdtempSync(join(tmpdir(), "apilot-multi-")); + temps.push(root); + for (const [dir, name] of [["services/api", "API"], ["apps/web", "Web"]] as const) { + const project = join(root, dir); + mkdirSync(join(project, ".apilot", "collections", "orders"), { recursive: true }); + writeFileSync(join(project, ".apilot", "apilot.yaml"), toYaml({ name, defaultEnvironment: "dev" })); + writeFileSync( + join(project, ".apilot", "collections", "orders", "list.yaml"), + toYaml({ name: "List orders", method: "GET", url: "{{baseUrl}}/orders" }) + ); + } + return new ProjectHub(discoverProjects([root])); + } + + afterEach(() => { + for (const t of temps.splice(0)) rmSync(t, { recursive: true, force: true }); + }); + + it("lists every project with its endpoints", async () => { + const hub = makeHub(); + const result = (await callTool(hub, "list_endpoints", {})) as any; + const ids = result.projects.filter((p: any) => p.hasApilot).map((p: any) => p.id).sort(); + expect(ids).toEqual(["apps/web", "services/api"]); + expect(result.endpoints).toHaveLength(2); + expect(result.endpoints.every((e: any) => !!e.project)).toBe(true); + }); + + it("resolves an endpoint in the project that owns it", async () => { + const hub = makeHub(); + const result = (await callTool(hub, "get_endpoint", { endpointId: "orders.list", project: "apps/web" })) as any; + expect(result.project).toBe("apps/web"); + expect(result.definition.method).toBe("GET"); + }); + + it("accepts a project-qualified endpoint id", async () => { + const hub = makeHub(); + const result = (await callTool(hub, "get_endpoint", { endpointId: "services/api::orders.list" })) as any; + expect(result.project).toBe("services/api"); + }); + + it("says which projects to choose from when an id is ambiguous", async () => { + const hub = makeHub(); + await expect(callTool(hub, "get_endpoint", { endpointId: "orders.list" })).rejects.toThrow(/more than one project/); + }); + + it("filters endpoints by project", async () => { + const hub = makeHub(); + const result = (await callTool(hub, "list_endpoints", { project: "apps/web" })) as any; + expect(result.endpoints).toHaveLength(1); + expect(result.endpoints[0].project).toBe("apps/web"); + }); + + it("initializes a folder that has no project yet", async () => { + const root = mkdtempSync(join(tmpdir(), "apilot-multi-")); + temps.push(root); + const hub = new ProjectHub(discoverProjects([root])); + expect(hub.exists()).toBe(false); + const result = (await callTool(hub, "init_project", { name: "Root API" })) as any; + expect(result.initialized).toBe(join(root, ".apilot")); + expect(hub.exists()).toBe(true); + }); +}); diff --git a/packages/mcp/src/tools.ts b/packages/mcp/src/tools.ts index 0712097..1d951c7 100644 --- a/packages/mcp/src/tools.ts +++ b/packages/mcp/src/tools.ts @@ -1,13 +1,26 @@ /** * The Apilot tool table. Every action the GUI offers has a tool here, and the - * same `callTool` runs in two places: + * same `callTool` runs in three places: * - the standalone MCP server (secrets from APILOT_SECRET_) * - the editor extension via the local bridge (secrets from the OS keychain) + * - the CLI * * The AI can do everything a user can except read or enter secret values. + * + * A workspace can hold several Apilot projects. Every endpoint tool takes an + * optional `project` so the AI can say which one it means; when the endpoint + * id is unique across the workspace, `project` can be left out. */ -import { importSpec, schemaToJsonSchema, type EndpointFile, type Workspace } from "@apilot/core"; +import { + importSpec, + isToolContext, + schemaToJsonSchema, + singleProjectContext, + type EndpointFile, + type ToolContext, + type Workspace, +} from "@apilot/core"; import { LANGUAGES, generate, writeGenerated } from "@apilot/codegen"; type Json = Record; @@ -16,6 +29,9 @@ const s = (description: string) => ({ type: "string", description }); const endpointId = s("Endpoint id, e.g. 'orders.list' (see list_endpoints)."); const env = s("Environment name, e.g. 'dev'. Defaults to the project's default environment."); const snapshotId = (which: string) => s(`Snapshot id (see list_snapshots). Defaults to ${which}.`); +const project = s( + "Apilot project id, e.g. 'services/api' (see list_endpoints). Omit when the endpoint id is unique in this workspace." +); const endpointFields = { name: s("Display name."), @@ -34,30 +50,30 @@ const endpointFields = { }; export const TOOLS = [ - { name: "list_endpoints", description: "List collections, endpoints (id, method, url), and environments. Call this first.", inputSchema: { type: "object", properties: { collectionId: s("Optional collection filter.") } } }, - { name: "get_endpoint", description: "Full endpoint definition plus its latest real (redacted) response. Use it instead of guessing request or response shapes.", inputSchema: { type: "object", properties: { endpointId }, required: ["endpointId"] } }, - { name: "add_endpoint", description: "Create an endpoint file in .apilot/collections//.", inputSchema: { type: "object", properties: { collection: s("Collection folder, e.g. 'orders'."), ...endpointFields }, required: ["name", "method", "url"] } }, - { name: "update_endpoint", description: "Change an endpoint. Only provided fields change; pass null to remove a field. Every change is recorded as a revision.", inputSchema: { type: "object", properties: { endpointId, ...endpointFields }, required: ["endpointId"] } }, - { name: "delete_endpoint", description: "Delete an endpoint file (its history and snapshots are kept).", inputSchema: { type: "object", properties: { endpointId }, required: ["endpointId"] } }, - { name: "run_endpoint", description: "Send the real request and return the redacted response, plus a diff against the baseline when the shape changed.", inputSchema: { type: "object", properties: { endpointId, env }, required: ["endpointId"] } }, - { name: "run_collection", description: "Run a collection (or everything) in order, with chaining. Returns pass/fail and breaking-change flags per endpoint.", inputSchema: { type: "object", properties: { collectionId: s("Collection to run; omit for all."), env } } }, - { name: "list_snapshots", description: "Saved responses for an endpoint, newest first (the baseline is flagged).", inputSchema: { type: "object", properties: { endpointId }, required: ["endpointId"] } }, - { name: "get_snapshot", description: "One saved (redacted) request/response.", inputSchema: { type: "object", properties: { endpointId, snapshotId: snapshotId("the latest") }, required: ["endpointId"] } }, - { name: "set_baseline", description: "Accept a response as the contract that future runs are compared against.", inputSchema: { type: "object", properties: { endpointId, snapshotId: snapshotId("the latest") }, required: ["endpointId"] } }, - { name: "delete_snapshot", description: "Delete a saved response (not the baseline).", inputSchema: { type: "object", properties: { endpointId, snapshotId: s("Snapshot id (see list_snapshots).") }, required: ["endpointId", "snapshotId"] } }, - { name: "diff_endpoint", description: "Classified response changes (breaking / warning / non-breaking / info). Default: baseline → latest.", inputSchema: { type: "object", properties: { endpointId, fromSnapshotId: snapshotId("the baseline"), toSnapshotId: snapshotId("the latest") }, required: ["endpointId"] } }, - { name: "impact_report", description: "Files and lines in the codebase that use fields the diff marks breaking or warning. Fix only these.", inputSchema: { type: "object", properties: { endpointId, fromSnapshotId: snapshotId("the baseline"), toSnapshotId: snapshotId("the latest") }, required: ["endpointId"] } }, - { name: "get_schema", description: "Response schema learned from every successful response: types, required and nullable fields, enums. Use before writing models.", inputSchema: { type: "object", properties: { endpointId }, required: ["endpointId"] } }, - { name: "generate_code", description: `Generate typed models + API call from real responses and write them (hand-written files are never overwritten). Languages: ${LANGUAGES.join(", ")}.`, inputSchema: { type: "object", properties: { endpointId, language: { type: "string", enum: [...LANGUAGES] }, outDir: s("Output dir relative to the project. Defaults to apilot.yaml codegen settings."), flavor: s("dart: 'freezed' (default) or 'plain'."), write: { type: "boolean", description: "Write files (default true). false returns the code only." } }, required: ["endpointId", "language"] } }, - { name: "list_revisions", description: "Version history of an endpoint's request definition (v1, v2, …) with change summaries.", inputSchema: { type: "object", properties: { endpointId }, required: ["endpointId"] } }, - { name: "diff_revisions", description: "What changed in the request definition between two revisions (default: previous → latest).", inputSchema: { type: "object", properties: { endpointId, from: { type: "number" }, to: { type: "number" } }, required: ["endpointId"] } }, - { name: "restore_revision", description: "Restore an endpoint definition to an earlier revision (recorded as a new revision).", inputSchema: { type: "object", properties: { endpointId, revision: { type: "number" } }, required: ["endpointId", "revision"] } }, - { name: "import_spec", description: "Import endpoints from a cURL command, Postman v2 collection, or OpenAPI/Swagger document (JSON or YAML). Credentials become secret variables, never file contents.", inputSchema: { type: "object", properties: { source: { type: "string", enum: ["curl", "postman", "openapi"] }, content: s("The cURL command or document text."), collection: s("Target collection (optional)."), env }, required: ["source", "content"] } }, - { name: "list_variables", description: "Variables in an environment: text values, and for secrets only whether a value is set. Secret values are never returned.", inputSchema: { type: "object", properties: { env } } }, - { name: "set_variable", description: "Set a text variable, or declare a secret variable (the user enters secret values in the Apilot panel).", inputSchema: { type: "object", properties: { env, name: s("Variable name."), type: { type: "string", enum: ["text", "secret"] }, value: s("Text value. Not allowed for secrets.") }, required: ["name"] } }, - { name: "delete_variable", description: "Remove a variable from an environment.", inputSchema: { type: "object", properties: { env, name: s("Variable name.") }, required: ["name"] } }, - { name: "create_environment", description: "Create an environment, optionally copying variables from another.", inputSchema: { type: "object", properties: { name: s("e.g. 'staging'."), copyFrom: s("Existing environment to copy.") }, required: ["name"] } }, - { name: "init_project", description: "Create a starter .apilot/ folder in the project (safe if it already exists).", inputSchema: { type: "object", properties: { name: s("Project name.") } } }, + { name: "list_endpoints", description: "List projects, collections, endpoints (id, project, method, url), and environments. Call this first.", inputSchema: { type: "object", properties: { collectionId: s("Optional collection filter."), project } } }, + { name: "get_endpoint", description: "Full endpoint definition plus its latest real (redacted) response. Use it instead of guessing request or response shapes.", inputSchema: { type: "object", properties: { endpointId, project }, required: ["endpointId"] } }, + { name: "add_endpoint", description: "Create an endpoint file in .apilot/collections// of a project.", inputSchema: { type: "object", properties: { collection: s("Collection folder, e.g. 'orders'."), project, ...endpointFields }, required: ["name", "method", "url"] } }, + { name: "update_endpoint", description: "Change an endpoint. Only provided fields change; pass null to remove a field. Every change is recorded as a revision.", inputSchema: { type: "object", properties: { endpointId, project, ...endpointFields }, required: ["endpointId"] } }, + { name: "delete_endpoint", description: "Delete an endpoint file (its history and snapshots are kept).", inputSchema: { type: "object", properties: { endpointId, project }, required: ["endpointId"] } }, + { name: "run_endpoint", description: "Send the real request and return the redacted response, plus a diff against the baseline when the shape changed.", inputSchema: { type: "object", properties: { endpointId, project, env }, required: ["endpointId"] } }, + { name: "run_collection", description: "Run a collection (or everything) in order, with chaining. Returns pass/fail and breaking-change flags per endpoint.", inputSchema: { type: "object", properties: { collectionId: s("Collection to run; omit for all."), project, env } } }, + { name: "list_snapshots", description: "Saved responses for an endpoint, newest first (the baseline is flagged).", inputSchema: { type: "object", properties: { endpointId, project }, required: ["endpointId"] } }, + { name: "get_snapshot", description: "One saved (redacted) request/response.", inputSchema: { type: "object", properties: { endpointId, project, snapshotId: snapshotId("the latest") }, required: ["endpointId"] } }, + { name: "set_baseline", description: "Accept a response as the contract that future runs are compared against.", inputSchema: { type: "object", properties: { endpointId, project, snapshotId: snapshotId("the latest") }, required: ["endpointId"] } }, + { name: "delete_snapshot", description: "Delete a saved response (not the baseline).", inputSchema: { type: "object", properties: { endpointId, project, snapshotId: s("Snapshot id (see list_snapshots).") }, required: ["endpointId", "snapshotId"] } }, + { name: "diff_endpoint", description: "Classified response changes (breaking / warning / non-breaking / info). Default: baseline → latest.", inputSchema: { type: "object", properties: { endpointId, project, fromSnapshotId: snapshotId("the baseline"), toSnapshotId: snapshotId("the latest") }, required: ["endpointId"] } }, + { name: "impact_report", description: "Files and lines in the codebase that use fields the diff marks breaking or warning. Fix only these.", inputSchema: { type: "object", properties: { endpointId, project, fromSnapshotId: snapshotId("the baseline"), toSnapshotId: snapshotId("the latest") }, required: ["endpointId"] } }, + { name: "get_schema", description: "Response schema learned from every successful response: types, required and nullable fields, enums. Use before writing models.", inputSchema: { type: "object", properties: { endpointId, project }, required: ["endpointId"] } }, + { name: "generate_code", description: `Generate typed models + API call from real responses and write them (hand-written files are never overwritten). Languages: ${LANGUAGES.join(", ")}.`, inputSchema: { type: "object", properties: { endpointId, project, language: { type: "string", enum: [...LANGUAGES] }, outDir: s("Output dir relative to the project. Defaults to apilot.yaml codegen settings."), flavor: s("dart: 'freezed' (default) or 'plain'."), write: { type: "boolean", description: "Write files (default true). false returns the code only." } }, required: ["endpointId", "language"] } }, + { name: "list_revisions", description: "Version history of an endpoint's request definition (v1, v2, …) with change summaries.", inputSchema: { type: "object", properties: { endpointId, project }, required: ["endpointId"] } }, + { name: "diff_revisions", description: "What changed in the request definition between two revisions (default: previous → latest).", inputSchema: { type: "object", properties: { endpointId, project, from: { type: "number" }, to: { type: "number" } }, required: ["endpointId"] } }, + { name: "restore_revision", description: "Restore an endpoint definition to an earlier revision (recorded as a new revision).", inputSchema: { type: "object", properties: { endpointId, project, revision: { type: "number" } }, required: ["endpointId", "revision"] } }, + { name: "import_spec", description: "Import endpoints from a cURL command, Postman v2 collection, or OpenAPI/Swagger document (JSON or YAML). Credentials become secret variables, never file contents.", inputSchema: { type: "object", properties: { source: { type: "string", enum: ["curl", "postman", "openapi"] }, content: s("The cURL command or document text."), collection: s("Target collection (optional)."), project, env }, required: ["source", "content"] } }, + { name: "list_variables", description: "Variables in an environment: text values, and for secrets only whether a value is set. Secret values are never returned.", inputSchema: { type: "object", properties: { project, env } } }, + { name: "set_variable", description: "Set a text variable, or declare a secret variable (the user enters secret values in the Apilot panel).", inputSchema: { type: "object", properties: { project, env, name: s("Variable name."), type: { type: "string", enum: ["text", "secret"] }, value: s("Text value. Not allowed for secrets.") }, required: ["name"] } }, + { name: "delete_variable", description: "Remove a variable from an environment.", inputSchema: { type: "object", properties: { project, env, name: s("Variable name.") }, required: ["name"] } }, + { name: "create_environment", description: "Create an environment, optionally copying variables from another.", inputSchema: { type: "object", properties: { project, name: s("e.g. 'staging'."), copyFrom: s("Existing environment to copy.") }, required: ["name"] } }, + { name: "init_project", description: "Create a starter .apilot/ folder in a project (safe if it already exists).", inputSchema: { type: "object", properties: { project, name: s("Project name.") } } }, ] as const; export type ToolName = (typeof TOOLS)[number]["name"]; @@ -77,116 +93,194 @@ function pickEndpoint(args: Json): Partial { return out as Partial; } -export async function callTool(ws: Workspace, name: string, args: Json = {}): Promise { +/** + * Run a tool. + * + * `ctx` is either a `Workspace` (one project) or a `ToolContext` (a hub of + * every project in the workspace). Both work; the hub is what the editor and + * the MCP server use. + */ +export async function callTool(ctx: Workspace | ToolContext, name: string, args: Json = {}): Promise { + const c: ToolContext = isToolContext(ctx) ? ctx : singleProjectContext(ctx); + const projectId = args.project as string | undefined; const id = args.endpointId as string; - if (name !== "init_project" && !ws.exists()) { + + if (name !== "init_project" && !c.exists()) { throw new Error("No Apilot project here. Call init_project first, or open a folder that contains .apilot/apilot.yaml."); } + + /** Resolve the endpoint and hand back its workspace. */ + const endpoint = () => c.resolveEndpoint(id, projectId); + /** The workspace a project-scoped command applies to. */ + const ws = (): Workspace => c.workspace(projectId); + switch (name as ToolName) { case "list_endpoints": { - const reg = ws.registry(); const filter = args.collectionId as string | undefined; + const entries = c + .listEndpoints(projectId) + .filter(({ endpoint }) => !filter || endpoint.collectionId === filter || endpoint.collectionId.startsWith(`${filter}/`)); + const projects = c.projects.map((p) => { + const reg = c.workspace(p.id).registry(); + return { + id: p.id, + name: p.name, + root: p.root, + hasApilot: p.hasApilot, + defaultEnvironment: reg.project.defaultEnvironment, + environments: reg.environments.map((e) => e.name), + endpointCount: reg.endpoints.length, + }; + }); + const def = c.workspace(projectId).registry().project; + const collections = c.projects.flatMap((p) => + c.workspace(p.id).registry().collections.map((col) => ({ id: col.id, name: col.name, project: p.id })) + ); return { - project: reg.project.name, - defaultEnvironment: reg.project.defaultEnvironment, - environments: reg.environments.map((e) => e.name), - collections: reg.collections.map((c) => ({ id: c.id, name: c.name })), - endpoints: reg.endpoints - .filter((e) => !filter || e.collectionId === filter || e.collectionId.startsWith(`${filter}/`)) - .map((e) => ({ id: e.id, name: e.name, method: e.method, url: e.url, file: e.file, snapshots: ws.store.list(e.id).length })), + project: def.name, + defaultEnvironment: def.defaultEnvironment, + environments: [...new Set(projects.flatMap((p) => p.environments))], + projects, + collections, + endpoints: entries.map(({ project, endpoint }) => ({ + id: endpoint.id, + project: project.id, + name: endpoint.name, + method: endpoint.method, + url: endpoint.url, + file: endpoint.file, + snapshots: c.workspace(project.id).store.list(endpoint.id).length, + })), }; } case "get_endpoint": { - const { entry, definition } = ws.endpoint(id); - const latest = ws.store.latest(id); + const { project, ws: workspace, endpointId: eid } = endpoint(); + const { entry, definition } = workspace.endpoint(eid); + const latest = workspace.store.latest(eid); return { - id, file: entry.file, definition, - revision: ws.revisions(id)[0]?.revision, + id: eid, + project: project.id, + file: entry.file, + definition, + revision: workspace.revisions(eid)[0]?.revision, latestResponse: latest ? { snapshotId: latest.id, timestamp: latest.timestamp, status: latest.status, headers: latest.headers, body: clip(latest.body) } : null, hint: latest ? undefined : "No response saved yet — call run_endpoint.", }; } case "add_endpoint": { const def = pickEndpoint(args) as EndpointFile; - return ws.saveEndpoint({ ...def, collection: args.collection as string | undefined, label: args.label as string | undefined }); + const workspace = c.ensureProject(projectId); + return workspace.saveEndpoint({ ...def, collection: args.collection as string | undefined, label: args.label as string | undefined }); } case "update_endpoint": { - const { definition } = ws.endpoint(id); - return ws.saveEndpoint({ ...definition, ...pickEndpoint(args), id, label: args.label as string | undefined }); + const { ws: workspace, endpointId: eid } = endpoint(); + const { definition } = workspace.endpoint(eid); + return workspace.saveEndpoint({ ...definition, ...pickEndpoint(args), id: eid, label: args.label as string | undefined }); + } + case "delete_endpoint": { + const { ws: workspace, endpointId: eid } = endpoint(); + workspace.deleteEndpoint(eid); + return { deleted: eid }; } - case "delete_endpoint": - ws.deleteEndpoint(id); - return { deleted: id }; case "run_endpoint": { - const r = await ws.run(id, args.env as string | undefined); + const { project, ws: workspace, endpointId: eid } = endpoint(); + const r = await workspace.run(eid, args.env as string | undefined); return { - endpointId: id, env: r.env, snapshotId: r.snapshot.id, + endpointId: eid, + project: project.id, + env: r.env, + snapshotId: r.snapshot.id, request: { method: r.snapshot.request.method, url: r.snapshot.request.url }, - status: r.snapshot.status, passed: r.snapshot.passed, failures: r.snapshot.failures, timeMs: r.snapshot.timeMs, - headers: r.snapshot.headers, body: clip(r.snapshot.body), + status: r.snapshot.status, + passed: r.snapshot.passed, + failures: r.snapshot.failures, + timeMs: r.snapshot.timeMs, + headers: r.snapshot.headers, + body: clip(r.snapshot.body), captured: Object.keys(r.captured).length ? r.captured : undefined, diffAgainstBaseline: r.diff && r.diff.changes.length ? { breaking: r.diff.breaking, summary: r.diff.summary, changes: r.diff.changes } : undefined, }; } case "run_collection": - return ws.runAll(args.collectionId as string | undefined, args.env as string | undefined); - case "list_snapshots": - return ws.snapshots(id); + return ws().runAll(args.collectionId as string | undefined, args.env as string | undefined); + case "list_snapshots": { + const { ws: workspace, endpointId: eid } = endpoint(); + return workspace.snapshots(eid); + } case "get_snapshot": { - const snap = ws.snapshot(id, args.snapshotId as string | undefined); + const { ws: workspace, endpointId: eid } = endpoint(); + const snap = workspace.snapshot(eid, args.snapshotId as string | undefined); return { ...snap, body: clip(snap.body) }; } - case "set_baseline": - return { endpointId: id, baseline: ws.setBaseline(id, args.snapshotId as string | undefined) }; - case "delete_snapshot": - ws.deleteSnapshot(id, args.snapshotId as string); + case "set_baseline": { + const { ws: workspace, endpointId: eid } = endpoint(); + return { endpointId: eid, baseline: workspace.setBaseline(eid, args.snapshotId as string | undefined) }; + } + case "delete_snapshot": { + const { ws: workspace, endpointId: eid } = endpoint(); + workspace.deleteSnapshot(eid, args.snapshotId as string); return { deleted: args.snapshotId }; - case "diff_endpoint": - return ws.diff(id, args.fromSnapshotId as string | undefined, args.toSnapshotId as string | undefined); + } + case "diff_endpoint": { + const { ws: workspace, endpointId: eid } = endpoint(); + return workspace.diff(eid, args.fromSnapshotId as string | undefined, args.toSnapshotId as string | undefined); + } case "impact_report": { - const diff = ws.diff(id, args.fromSnapshotId as string | undefined, args.toSnapshotId as string | undefined); - const { fields, matches } = ws.impact(id, diff); - return { endpointId: id, breaking: diff.breaking, changedFields: fields, matches, summary: matches.length ? `${new Set(matches.map((m) => m.file)).size} file(s) use changed fields.` : "No code references to changed fields found." }; + const { ws: workspace, endpointId: eid } = endpoint(); + const diff = workspace.diff(eid, args.fromSnapshotId as string | undefined, args.toSnapshotId as string | undefined); + const { fields, matches } = workspace.impact(eid, diff); + return { endpointId: eid, breaking: diff.breaking, changedFields: fields, matches, summary: matches.length ? `${new Set(matches.map((m) => m.file)).size} file(s) use changed fields.` : "No code references to changed fields found." }; } case "get_schema": { - const schema = ws.schema(id); - return { endpointId: id, samples: ws.store.list(id).filter((s) => s.passed).length, schema: schemaToJsonSchema(schema) }; + const { ws: workspace, endpointId: eid } = endpoint(); + const schema = workspace.schema(eid); + return { endpointId: eid, samples: workspace.store.list(eid).filter((s) => s.passed).length, schema: schemaToJsonSchema(schema) }; } case "generate_code": { - const files = generate(ws, id, args.language as string, { outDir: args.outDir as string | undefined, flavor: args.flavor as string | undefined }); + const { ws: workspace, endpointId: eid } = endpoint(); + const files = generate(workspace, eid, args.language as string, { outDir: args.outDir as string | undefined, flavor: args.flavor as string | undefined }); if (args.write === false) return { files }; - return { ...writeGenerated(ws.root, files), note: "Files marked GENERATED BY APILOT are regenerated; hand-written files are skipped." }; - } - case "list_revisions": - return ws.revisions(id).map(({ definition, ...rest }) => ({ ...rest, method: definition.method, url: definition.url })); - case "diff_revisions": - return ws.diffRevisions(id, args.from as number | undefined, args.to as number | undefined); - case "restore_revision": - return { endpointId: id, revision: ws.restoreRevision(id, args.revision as number) }; + return { ...writeGenerated(workspace.root, files), note: "Files marked GENERATED BY APILOT are regenerated; hand-written files are skipped." }; + } + case "list_revisions": { + const { ws: workspace, endpointId: eid } = endpoint(); + return workspace.revisions(eid).map(({ definition, ...rest }) => ({ ...rest, method: definition.method, url: definition.url })); + } + case "diff_revisions": { + const { ws: workspace, endpointId: eid } = endpoint(); + return workspace.diffRevisions(eid, args.from as number | undefined, args.to as number | undefined); + } + case "restore_revision": { + const { ws: workspace, endpointId: eid } = endpoint(); + return { endpointId: eid, revision: workspace.restoreRevision(eid, args.revision as number) }; + } case "import_spec": - return importSpec(ws, args.source as "curl" | "postman" | "openapi", args.content as string, args.collection as string | undefined, args.env as string | undefined); + return importSpec(c.ensureProject(projectId), args.source as "curl" | "postman" | "openapi", args.content as string, args.collection as string | undefined, args.env as string | undefined); case "list_variables": { - const envName = ws.envName(args.env as string | undefined); - return { env: envName, variables: await ws.variables(envName) }; + const workspace = ws(); + const envName = workspace.envName(args.env as string | undefined); + return { env: envName, variables: await workspace.variables(envName) }; } case "set_variable": { const type = (args.type as "text" | "secret" | undefined) ?? "text"; if (type === "secret" && args.value !== undefined) throw new Error("Secret values can't be set by tools. Declare the secret, then ask the user to enter it in the Apilot panel → Environments."); - const envName = ws.envName(args.env as string | undefined); - await ws.setVariable(envName, args.name as string, type, type === "text" ? String(args.value ?? "") : undefined); + const workspace = ws(); + const envName = workspace.envName(args.env as string | undefined); + await workspace.setVariable(envName, args.name as string, type, type === "text" ? String(args.value ?? "") : undefined); return { env: envName, name: args.name, type }; } case "delete_variable": { - const envName = ws.envName(args.env as string | undefined); - await ws.deleteVariable(envName, args.name as string); + const workspace = ws(); + const envName = workspace.envName(args.env as string | undefined); + await workspace.deleteVariable(envName, args.name as string); return { env: envName, deleted: args.name }; } - case "create_environment": - ws.createEnvironment(args.name as string, args.copyFrom as string | undefined); + case "create_environment": { + ws().createEnvironment(args.name as string, args.copyFrom as string | undefined); return { created: args.name }; + } case "init_project": - ws.init((args.name as string | undefined) ?? "my-api"); - return { initialized: ws.apilotDir }; + return c.initProject((args.name as string | undefined) ?? "my-api", projectId); default: throw new Error(`Unknown tool "${name}".`); } diff --git a/packages/skill/src/index.ts b/packages/skill/src/index.ts index 42ea0f6..dd12385 100644 --- a/packages/skill/src/index.ts +++ b/packages/skill/src/index.ts @@ -21,12 +21,22 @@ Apilot's MCP tools let you do everything the Apilot panel can. **Never guess a f type, or nullability — look at a real response.** ## Before writing API code -1. \`list_endpoints\` — find the endpoint id (e.g. \`orders.list\`). +1. \`list_endpoints\` — find the endpoint id (e.g. \`orders.list\`) and the project it belongs to. 2. \`get_endpoint\` — definition + latest real response. If there is none, \`run_endpoint\`. 3. \`get_schema\` — types, required vs optional, nullable, enums (learned from every saved response). 4. \`generate_code\` (dart | typescript | kotlin | swift) — writes models + a typed call. Use them; don't hand-write models for Apilot endpoints. +## Several projects in one workspace +A folder can hold more than one Apilot project (the folder itself, and any nested folder with +an \`.apilot/apilot.yaml\`). \`list_endpoints\` returns each project with its **id** and **root**. +Every endpoint tool takes an optional \`project\`: + +- If an endpoint id is unique in the workspace, leave \`project\` out. +- If the same id exists in two projects, pass \`project: ""\` (e.g. \`"services/api"\`). +- You can also write the id as \`project::endpoint\`, e.g. \`services/api::orders.list\`. +- \`run_collection\`, \`add_endpoint\`, \`import_spec\`, and the variable tools are project-scoped too. + ## When the backend changes 1. \`run_endpoint\` (or \`run_collection\`) — each run is compared to the endpoint's **baseline**. 2. \`diff_endpoint\` — classified changes: **breaking** (field removed, type changed, can now be null, @@ -58,6 +68,7 @@ type, or nullability — look at a real response.** - \`.apilot/snapshots//*.json\` — real responses (redacted); \`baseline\` holds the accepted snapshot id - \`.apilot/history//.yaml\` — request revisions - CLI: \`apilot run \`, \`apilot diff \`, \`apilot codegen --lang dart\`, \`apilot check\` + (add \`--project \` to target one project) Endpoint file example: \`\`\`yaml diff --git a/packages/webview/src/App.test.tsx b/packages/webview/src/App.test.tsx index d934d69..0706398 100644 --- a/packages/webview/src/App.test.tsx +++ b/packages/webview/src/App.test.tsx @@ -1,16 +1,19 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, waitFor, fireEvent, cleanup, act } from "@testing-library/react"; +import { render, screen, waitFor, fireEvent, cleanup, act, within } from "@testing-library/react"; const mockState = { hasProject: true, hasFolder: true, project: { name: "My API", defaultEnvironment: "dev" }, + activeProject: "repo", + projects: [{ id: "repo", name: "My API", root: "/repo", relPath: "", hasApilot: true }], env: "dev", environments: ["dev", "staging"], + projectEnvs: { repo: { env: "dev", environments: ["dev", "staging"] } }, collections: [{ id: "orders", name: "Orders", path: ".apilot/collections/orders" }], endpoints: [ - { id: "orders.list", collectionId: "orders", name: "List orders", method: "GET", url: "{{baseUrl}}/orders", file: ".apilot/collections/orders/list.yaml", health: { state: "passed" as const } }, - { id: "orders.get", collectionId: "orders", name: "Get order", method: "GET", url: "{{baseUrl}}/orders/{{id}}", file: ".apilot/collections/orders/get.yaml", health: { state: "breaking" as const, status: 500 } }, + { id: "orders.list", collectionId: "orders", name: "List orders", method: "GET", url: "{{baseUrl}}/orders", file: ".apilot/collections/orders/list.yaml", project: "repo", health: { state: "passed" as const } }, + { id: "orders.get", collectionId: "orders", name: "Get order", method: "GET", url: "{{baseUrl}}/orders/{{id}}", file: ".apilot/collections/orders/get.yaml", project: "repo", health: { state: "breaking" as const, status: 500 } }, ], }; @@ -101,13 +104,13 @@ describe("App", () => { it("clicking 'New endpoint' switches to the new-endpoint view", async () => { render(); await screen.findByText("New endpoint"); - const overviewBtn = screen.getAllByText("New endpoint")[0]; + const overviewBtn = screen.getAllByText("New endpoint")[0]!; await act(async () => { fireEvent.click(overviewBtn); }); await waitFor(() => { expect(persisted.set).toHaveBeenCalledWith( - expect.objectContaining({ view: { kind: "new" } }) + expect.objectContaining({ view: { kind: "new", project: "repo" } }) ); }); }); @@ -115,13 +118,13 @@ describe("App", () => { it("clicking 'Import' switches to the import view", async () => { render(); await screen.findByText("Import"); - const overviewBtn = screen.getAllByText("Import")[0]; + const overviewBtn = screen.getAllByText("Import")[0]!; await act(async () => { fireEvent.click(overviewBtn); }); await waitFor(() => { expect(persisted.set).toHaveBeenCalledWith( - expect.objectContaining({ view: { kind: "import" } }) + expect.objectContaining({ view: { kind: "import", project: "repo" } }) ); }); }); @@ -135,8 +138,29 @@ describe("App", () => { }); await waitFor(() => { expect(persisted.set).toHaveBeenCalledWith( - expect.objectContaining({ view: { kind: "endpoint", id: "orders.get", tab: "changes" } }) + expect.objectContaining({ view: { kind: "endpoint", id: "orders.get", project: "repo", tab: "changes" } }) ); }); }); + + it("lists every project when the workspace holds more than one", async () => { + (persisted.get as any).mockReturnValue({ view: { kind: "home" }, navOpen: true }); + mockCall({ + ...mockState, + activeProject: "services/api", + projects: [ + { id: "repo", name: "Root", root: "/repo", relPath: "", hasApilot: false }, + { id: "services/api", name: "Service API", root: "/repo/services/api", relPath: "services/api", hasApilot: true }, + ], + projectEnvs: { "services/api": { env: "dev", environments: ["dev"] } }, + endpoints: [{ ...mockState.endpoints[0]!, project: "services/api" }], + }); + render(); + // The nested project is shown by name, and the root folder offers to initialize. + await screen.findByRole("heading", { name: "Service API" }); + const sidebar = document.querySelector(".sidebar")!; + expect(within(sidebar).getByText("Service API")).toBeInTheDocument(); + expect(within(sidebar).getByText("Root")).toBeInTheDocument(); + expect(within(sidebar).getByText("init")).toBeInTheDocument(); + }); }); diff --git a/packages/webview/src/App.tsx b/packages/webview/src/App.tsx index 2f2ae0e..ac553b1 100644 --- a/packages/webview/src/App.tsx +++ b/packages/webview/src/App.tsx @@ -8,13 +8,16 @@ import { Spinner } from "./components/ResultTabs"; export type View = | { kind: "home" } - | { kind: "endpoint"; id: string; tab?: string } - | { kind: "new" } - | { kind: "import" } - | { kind: "environments" } - | { kind: "runAll"; autorun?: boolean }; + | { kind: "endpoint"; id: string; project?: string; tab?: string } + | { kind: "new"; project?: string } + | { kind: "import"; project?: string } + | { kind: "environments"; project?: string } + | { kind: "runAll"; project?: string; autorun?: boolean }; -const sameView = (a: View, b: View) => a.kind === b.kind && (a.kind !== "endpoint" || a.id === (b as { id: string }).id); +const sameView = (a: View, b: View) => + a.kind === b.kind && + (a.kind !== "endpoint" || + (a.id === (b as { id: string }).id && (a.project ?? "") === ((b as { project?: string }).project ?? ""))); export default function App() { const [state, setState] = useState(null); @@ -49,23 +52,29 @@ export default function App() { onHostMessage((msg) => { if (msg.type === "changed") reload(); if (msg.type === "focus") { - if (msg.endpointId) setView({ kind: "endpoint", id: msg.endpointId, tab: msg.tab }); - else if (msg.view) setView({ kind: msg.view, autorun: msg.autorun } as View); + if (msg.endpointId) setView({ kind: "endpoint", id: msg.endpointId, project: msg.project, tab: msg.tab }); + else if (msg.view) setView({ kind: msg.view, project: msg.project, autorun: msg.autorun } as View); } }), [reload, setView] ); if (!state) return ; - if (!state.hasProject) return ; + if (!state.hasProject) return ; - const env = state.env!; - const changeEnv = (e: string) => call("setEnv", { env: e }).then(reload); - const known = view.kind !== "endpoint" || state.endpoints!.some((e) => e.id === view.id); + const projects = state.projects ?? []; + const activeProject = state.activeProject ?? projects[0]?.id ?? ""; + /** The project the user is looking at right now. */ + const viewProject = view.kind !== "home" ? view.project : undefined; + const focusProject = viewProject ?? activeProject; + const env = state.projectEnvs?.[focusProject]?.env ?? state.env ?? ""; + + const changeEnv = (e: string) => call("setEnv", { env: e, project: focusProject }).then(reload); + const known = view.kind !== "endpoint" || state.endpoints!.some((e) => e.id === view.id && e.project === (view.project ?? focusProject)); return (
- {navOpen && } + {navOpen && } {navOpen &&
}
{!navOpen && ( @@ -73,38 +82,47 @@ export default function App() { )} - {view.kind === "endpoint" && known && } - {view.kind === "new" && c.id)} />} - {view.kind === "import" && } - {view.kind === "environments" && } - {view.kind === "runAll" && } - {(view.kind === "home" || !known) && } + {view.kind === "endpoint" && known && ( + + )} + {view.kind === "new" && ( + c.id)} /> + )} + {view.kind === "import" && } + {view.kind === "environments" && ( + + )} + {view.kind === "runAll" && } + {(view.kind === "home" || !known) && }
); } -function Overview({ state, setView }: { state: AppState; setView: (v: View) => void }) { +function Overview({ state, setView, project }: { state: AppState; setView: (v: View) => void; project: string }) { const eps = state.endpoints!; const count = (s: string) => eps.filter((e) => e.health.state === s).length; const attention = eps.filter((e) => e.health.state === "breaking" || e.health.state === "failed" || e.health.state === "changed"); + const projects = state.projects ?? []; + const multi = projects.length > 1; return (
-

{state.project!.name}

+

{multi ? projects.find((p) => p.id === project)?.name ?? state.project!.name : state.project!.name}

{eps.length} endpoint{eps.length === 1 ? "" : "s"} · {state.env} + {multi && <> · {projects.filter((p) => p.hasApilot).length} projects}

- - -
@@ -129,11 +147,12 @@ function Overview({ state, setView }: { state: AppState; setView: (v: View) => v ) : (
    {attention.map((e) => ( -
  • -
  • diff --git a/packages/webview/src/api.ts b/packages/webview/src/api.ts index 106907e..49e68e3 100644 --- a/packages/webview/src/api.ts +++ b/packages/webview/src/api.ts @@ -52,14 +52,30 @@ export const HEALTH_LABEL: Record = { changed: "Changed (safe)", }; +/** One Apilot project found in the workspace. */ +export interface ProjectInfo { + id: string; + name: string; + root: string; + relPath: string; + hasApilot: boolean; +} + export interface AppState { hasProject: boolean; hasFolder: boolean; + /** The active project's apilot.yaml. */ project?: ApilotProject; + /** Id of the project the panel is showing. */ + activeProject?: string; + /** Every project in the workspace, including ones without `.apilot` yet. */ + projects?: ProjectInfo[]; env?: string; environments?: string[]; + /** Environment per project — each project has its own environments. */ + projectEnvs?: Record; collections?: CollectionEntry[]; - endpoints?: (EndpointEntry & { health: Health })[]; + endpoints?: (EndpointEntry & { health: Health; project: string })[]; } export const pretty = (v: unknown): string => (typeof v === "string" ? v : v === undefined ? "" : JSON.stringify(v, null, 2)); diff --git a/packages/webview/src/components/EndpointView.tsx b/packages/webview/src/components/EndpointView.tsx index c377923..6d8bf9e 100644 --- a/packages/webview/src/components/EndpointView.tsx +++ b/packages/webview/src/components/EndpointView.tsx @@ -37,8 +37,16 @@ export interface RunView { const EMPTY: EndpointFile = { name: "", method: "GET", url: "{{baseUrl}}/" }; -export function EndpointView(props: { id?: string; env: string; tab?: string; version: number; setView: (v: View) => void; collections?: string[] }) { - const { id, env, version, setView } = props; +export function EndpointView(props: { + id?: string; + project?: string; + env: string; + tab?: string; + version: number; + setView: (v: View) => void; + collections?: string[]; +}) { + const { id, project, env, version, setView } = props; const [detail, setDetail] = useState(null); const [draft, setDraft] = useState(EMPTY); const [collection, setCollection] = useState(props.collections?.[0] ?? ""); @@ -60,7 +68,7 @@ export function EndpointView(props: { id?: string; env: string; tab?: string; ve const load = useCallback(async () => { if (!id) return; try { - const d = await call("endpoint", { id }); + const d = await call("endpoint", { id, project }); setDetail(d); if (!dirtyRef.current) setDraft(d.definition); setShown((cur) => (cur && d.snapshots.some((s) => s.id === cur.snapshot.id) ? cur : d.latest ? { snapshot: d.latest } : null)); @@ -68,7 +76,7 @@ export function EndpointView(props: { id?: string; env: string; tab?: string; ve } catch (e) { setError((e as Error).message); } - }, [id]); + }, [id, project]); useEffect(() => { load(); @@ -86,7 +94,7 @@ export function EndpointView(props: { id?: string; env: string; tab?: string; ve } setBusy("save"); try { - const r = await call<{ id: string }>("save", { ...clean(draft), id, collection: id ? undefined : collection || undefined }); + const r = await call<{ id: string }>("save", { ...clean(draft), id, project, collection: id ? undefined : collection || undefined }); setDirty(false); unsaved.current = false; setError(""); @@ -106,7 +114,7 @@ export function EndpointView(props: { id?: string; env: string; tab?: string; ve setBusy("run"); setError(""); try { - const r = await call<{ snapshot: SnapshotRedacted; captured: Record; diff?: { changes: unknown[] } }>("run", { id: target, env }); + const r = await call<{ snapshot: SnapshotRedacted; captured: Record; diff?: { changes: unknown[] } }>("run", { id: target, project, env }); setShown({ snapshot: r.snapshot, captured: r.captured }); setResTab(r.diff?.changes.length ? "changes" : "response"); } catch (e) { @@ -118,7 +126,7 @@ export function EndpointView(props: { id?: string; env: string; tab?: string; ve const remove = async () => { if (!id || !(await confirm(`Delete “${draft.name || id}”?`, "Delete", `Deletes ${detail?.file ?? "the endpoint file"}.`))) return; - await call("tool:delete_endpoint", { endpointId: id }); + await call("tool:delete_endpoint", { endpointId: id, project }); unsaved.current = false; setView({ kind: "home" }); }; @@ -169,7 +177,7 @@ export function EndpointView(props: { id?: string; env: string; tab?: string; ve {detail && ( <> -
setNewEnv(e.target.value)} aria-label="New environment name" /> -
@@ -104,7 +111,7 @@ export function EnvironmentsPage({ env, environments, version, changeEnv }: { en {vars.map((v) => ( - + ))} @@ -139,7 +146,7 @@ export function EnvironmentsPage({ env, environments, version, changeEnv }: { en ); } -function VariableRow({ env, v, act }: { env: string; v: Variable; act: (p: Promise, msg?: string) => Promise }) { +function VariableRow({ env, project, v, act }: { env: string; project?: string; v: Variable; act: (p: Promise, msg?: string) => Promise }) { const [value, setValue] = useState(v.value ?? ""); const [secret, setSecret] = useState(""); const saved = useRef(v.value ?? ""); @@ -159,7 +166,7 @@ function VariableRow({ env, v, act }: { env: string; v: Variable; act: (p: Promi setValue(e.target.value)} - onBlur={() => value !== saved.current && act(call("tool:set_variable", { env, name: v.name, type: "text", value }))} + onBlur={() => value !== saved.current && act(call("tool:set_variable", { env, project, name: v.name, type: "text", value }))} onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()} spellCheck={false} aria-label={`${v.name} value`} @@ -168,7 +175,7 @@ function VariableRow({ env, v, act }: { env: string; v: Variable; act: (p: Promi
setSecret(e.target.value)} aria-label={`${v.name} secret value`} /> -
@@ -179,7 +186,7 @@ function VariableRow({ env, v, act }: { env: string; v: Variable; act: (p: Promi className="icon danger" aria-label={`Delete ${v.name}`} title="Delete variable" - onClick={async () => (await confirm(`Delete “${v.name}” from ${env}?`, "Delete", v.type === "secret" ? "Its keychain value is removed too." : undefined)) && act(call("tool:delete_variable", { env, name: v.name }))} + onClick={async () => (await confirm(`Delete “${v.name}” from ${env}?`, "Delete", v.type === "secret" ? "Its keychain value is removed too." : undefined)) && act(call("tool:delete_variable", { env, project, name: v.name }))} > @@ -190,7 +197,7 @@ function VariableRow({ env, v, act }: { env: string; v: Variable; act: (p: Promi // --------------------------------------------------------------------------- -export function ImportPage({ env, setView }: { env: string; setView: (v: View) => void }) { +export function ImportPage({ project, env, setView }: { project?: string; env: string; setView: (v: View) => void }) { const [source, setSource] = useState<"curl" | "postman" | "openapi">("curl"); const [content, setContent] = useState(""); const [collection, setCollection] = useState(""); @@ -209,7 +216,7 @@ export function ImportPage({ env, setView }: { env: string; setView: (v: View) = const run = async () => { setBusy(true); try { - setResult(await call("tool:import_spec", { source, content, collection: collection || undefined, env })); + setResult(await call("tool:import_spec", { source, content, collection: collection || undefined, project, env })); setError(""); } catch (e) { setError((e as Error).message); @@ -253,7 +260,7 @@ export function ImportPage({ env, setView }: { env: string; setView: (v: View) =
    {result.created.map((id) => (
  • - +
  • ))}
@@ -274,7 +281,7 @@ interface RunAllResult { results: { endpointId: string; name: string; method: string; status?: number; timeMs?: number; passed: boolean; breaking: boolean; error?: string }[]; } -export function RunAllPage({ env, state, autorun, setView }: { env: string; state: AppState; autorun?: boolean; setView: (v: View) => void }) { +export function RunAllPage({ project, env, state, autorun, setView }: { project?: string; env: string; state: AppState; autorun?: boolean; setView: (v: View) => void }) { const [collection, setCollection] = useState(""); const [busy, setBusy] = useState(false); const [result, setResult] = useState(null); @@ -285,7 +292,7 @@ export function RunAllPage({ env, state, autorun, setView }: { env: string; stat setBusy(true); setError(""); try { - setResult(await call("tool:run_collection", { collectionId: collection || undefined, env })); + setResult(await call("tool:run_collection", { collectionId: collection || undefined, project, env })); } catch (e) { setError((e as Error).message); } finally { @@ -334,8 +341,8 @@ export function RunAllPage({ env, state, autorun, setView }: { env: string; stat key={r.endpointId} className="clickable" tabIndex={0} - onClick={() => setView({ kind: "endpoint", id: r.endpointId, tab: r.breaking ? "changes" : "response" })} - onKeyDown={(e) => e.key === "Enter" && setView({ kind: "endpoint", id: r.endpointId, tab: r.breaking ? "changes" : "response" })} + onClick={() => setView({ kind: "endpoint", id: r.endpointId, project, tab: r.breaking ? "changes" : "response" })} + onKeyDown={(e) => e.key === "Enter" && setView({ kind: "endpoint", id: r.endpointId, project, tab: r.breaking ? "changes" : "response" })} > {r.method} {r.name} {r.status ? {r.status} : "—"} diff --git a/packages/webview/src/components/ResultTabs.tsx b/packages/webview/src/components/ResultTabs.tsx index 9c0fab3..191bac9 100644 --- a/packages/webview/src/components/ResultTabs.tsx +++ b/packages/webview/src/components/ResultTabs.tsx @@ -136,10 +136,10 @@ interface Pair { impact: { fields: string[]; matches: { file: string; line: number; field: string; text: string }[] }; } -export function ChangesTab({ id, snapshots, version }: { id: string; snapshots: SnapshotMeta[]; version: number }) { +export function ChangesTab({ id, project, snapshots, version }: { id: string; project?: string; snapshots: SnapshotMeta[]; version: number }) { const [from, setFrom] = useState(); const [to, setTo] = useState(); - const { data, error, reload } = useLoad(() => call("snapshotPair", { id, from, to }), [id, from, to, version]); + const { data, error, reload } = useLoad(() => call("snapshotPair", { id, project, from, to }), [id, project, from, to, version]); if (snapshots.length < 1) return No responses yet. Send the request — the first successful response becomes the baseline, and later responses are compared to it.; if (error) return {error}; @@ -160,10 +160,10 @@ export function ChangesTab({ id, snapshots, version }: { id: string; snapshots: {!same && ( <> - - @@ -205,7 +205,7 @@ export function ChangesTab({ id, snapshots, version }: { id: string; snapshots:
    {data.impact.matches.slice(0, 50).map((m, i) => (
  • - {" "} {m.text.trim().slice(0, 120)} @@ -232,7 +232,7 @@ export function ChangesTab({ id, snapshots, version }: { id: string; snapshots: // --------------------------------------------------------------------------- -export function HistoryTab({ id, snapshots, shownId, show }: { id: string; snapshots: SnapshotMeta[]; shownId?: string; show: (id: string) => void }) { +export function HistoryTab({ id, project, snapshots, shownId, show }: { id: string; project?: string; snapshots: SnapshotMeta[]; shownId?: string; show: (id: string) => void }) { if (!snapshots.length) return No saved responses yet.; const baseline = snapshots.find((s) => s.baseline); return ( @@ -260,12 +260,12 @@ export function HistoryTab({ id, snapshots, shownId, show }: { id: string; snaps {baseline && !s.baseline && ( - )} {!s.baseline && s.passed && ( - )} @@ -289,14 +289,14 @@ export function HistoryTab({ id, snapshots, shownId, show }: { id: string; snaps // --------------------------------------------------------------------------- -export function VersionsTab({ id, revisions, dirty }: { id: string; revisions: Revision[]; dirty: boolean }) { +export function VersionsTab({ id, project, revisions, dirty }: { id: string; project?: string; revisions: Revision[]; dirty: boolean }) { const latest = revisions[0]?.revision ?? 0; const [from, setFrom] = useState(); const [to, setTo] = useState(); const enough = revisions.length >= 2; const { data, error } = useLoad<{ from: Revision; to: Revision; changes: RevisionChange[] }>( - () => (enough ? call("tool:diff_revisions", { endpointId: id, from, to }) : Promise.resolve(undefined as never)), - [id, from, to, latest] + () => (enough ? call("tool:diff_revisions", { endpointId: id, project, from, to }) : Promise.resolve(undefined as never)), + [id, project, from, to, latest] ); return ( @@ -312,7 +312,7 @@ export function VersionsTab({ id, revisions, dirty }: { id: string; revisions: R className="link" disabled={dirty} title={dirty ? "Save or discard your edits first" : `Restore v${r.revision} as a new version`} - onClick={async () => (await confirm(`Restore v${r.revision}?`, "Restore", "The current request is kept as a version, so you can undo this.")) && call("tool:restore_revision", { endpointId: id, revision: r.revision })} + onClick={async () => (await confirm(`Restore v${r.revision}?`, "Restore", "The current request is kept as a version, so you can undo this.")) && call("tool:restore_revision", { endpointId: id, project, revision: r.revision })} > Restore @@ -335,7 +335,7 @@ export function VersionsTab({ id, revisions, dirty }: { id: string; revisions: R {revisions.map((r) => )} - @@ -362,8 +362,8 @@ const short = (v: unknown) => { // --------------------------------------------------------------------------- -export function SchemaTab({ id, version }: { id: string; version: number }) { - const { data, error } = useLoad(() => call("schema", { id }), [id, version]); +export function SchemaTab({ id, project, version }: { id: string; project?: string; version: number }) { + const { data, error } = useLoad(() => call("schema", { id, project }), [id, project, version]); if (error) return {error}; if (!data) return ; if (!data.type) return No schema yet. It is learned from successful responses, so send the request first.; @@ -420,13 +420,13 @@ const LANGS = [ ["swift", "Swift"], ] as const; -export function CodeTab({ id, version }: { id: string; version: number }) { +export function CodeTab({ id, project, version }: { id: string; project?: string; version: number }) { const [language, setLanguage] = useState(() => persisted.get().lang ?? "dart"); const [flavor, setFlavor] = useState("freezed"); const [files, setFiles] = useState<{ path: string; content: string }[]>([]); const [result, setResult] = useState<{ written: string[]; unchanged: string[]; skipped: string[] } | null>(null); const [error, setError] = useState(""); - const args = { endpointId: id, language, flavor: language === "dart" ? flavor : undefined }; + const args = { endpointId: id, project, language, flavor: language === "dart" ? flavor : undefined }; useEffect(() => { persisted.set({ ...persisted.get(), lang: language }); @@ -435,7 +435,7 @@ export function CodeTab({ id, version }: { id: string; version: number }) { .then((r) => (setFiles(r.files), setError(""))) .catch((e: Error) => (setFiles([]), setError(e.message))); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [id, language, flavor, version]); + }, [id, project, language, flavor, version]); return (
    @@ -457,9 +457,9 @@ export function CodeTab({ id, version }: { id: string; version: number }) { {error &&
    {error}
    } {result && (
    - {result.written.length ? <>Wrote {result.written.map((f) => )} : null} - {result.unchanged.length ? <>Up to date: {result.unchanged.map((f) => )} : null} - {result.skipped.length ? <>Skipped hand-written: {result.skipped.map((f) => )} : null} + {result.written.length ? <>Wrote {result.written.map((f) => )} : null} + {result.unchanged.length ? <>Up to date: {result.unchanged.map((f) => )} : null} + {result.skipped.length ? <>Skipped hand-written: {result.skipped.map((f) => )} : null}
    )} {files.map((f) => ( @@ -476,9 +476,9 @@ export function CodeTab({ id, version }: { id: string; version: number }) { ); } -function FileLink({ file }: { file: string }) { +function FileLink({ file, project }: { file: string; project?: string }) { return ( - ); diff --git a/packages/webview/src/components/Sidebar.tsx b/packages/webview/src/components/Sidebar.tsx index db4ec85..11236eb 100644 --- a/packages/webview/src/components/Sidebar.tsx +++ b/packages/webview/src/components/Sidebar.tsx @@ -1,26 +1,88 @@ import { useState } from "react"; -import { ChevronDown, ChevronRight, Download, KeyRound, LayoutDashboard, PanelLeftClose, Play, Plus, Search } from "lucide-react"; -import { HEALTH_LABEL, type AppState } from "../api"; +import { ChevronDown, ChevronRight, Download, FolderPlus, KeyRound, LayoutDashboard, PanelLeftClose, Play, Plus, Search } from "lucide-react"; +import { call, HEALTH_LABEL, type AppState } from "../api"; import type { View } from "../App"; -export function Sidebar({ state, view, setView, changeEnv, onCollapse }: { state: AppState; view: View; setView: (v: View) => void; changeEnv: (e: string) => void; onCollapse: () => void }) { +interface Endpoint { + id: string; + collectionId: string; + name: string; + method: string; + url: string; + project: string; + health: { status?: number; state: "none" | "passed" | "failed" | "breaking" | "changed" }; +} + +export function Sidebar({ + state, + view, + setView, + changeEnv, + onCollapse, + project, +}: { + state: AppState; + view: View; + setView: (v: View) => void; + changeEnv: (e: string) => void; + onCollapse: () => void; + project: string; +}) { const [q, setQ] = useState(""); const [closed, setClosed] = useState>(new Set()); + const [closedProjects, setClosedProjects] = useState>(new Set()); const needle = q.toLowerCase(); - const eps = state.endpoints!.filter((e) => !needle || `${e.name} ${e.url} ${e.id}`.toLowerCase().includes(needle)); - const groups = [...new Set(eps.map((e) => e.collectionId))]; + const eps = (state.endpoints ?? []).filter((e) => !needle || `${e.name} ${e.url} ${e.id}`.toLowerCase().includes(needle)); + const projects = state.projects ?? []; + const multi = projects.length > 1; const name = (id: string) => state.collections!.find((c) => c.id === id)?.name ?? (id || "Endpoints"); const toggle = (id: string) => setClosed((s) => (s.has(id) ? (s.delete(id), new Set(s)) : new Set(s.add(id)))); + const toggleProject = (id: string) => setClosedProjects((s) => (s.has(id) ? (s.delete(id), new Set(s)) : new Set(s.add(id)))); const nav = (kind: View["kind"], label: string, icon: JSX.Element) => ( - ); + /** Endpoint rows for one project, grouped by collection. */ + const collectionsOf = (list: Endpoint[]) => { + const groups = [...new Set(list.map((e) => e.collectionId))]; + return groups.map((g) => { + const items = list.filter((e) => e.collectionId === g); + return ( +
    + + {!closed.has(g) && + items.map((e) => { + const active = view.kind === "endpoint" && view.id === e.id && (view.project ?? project) === e.project; + return ( + + ); + })} +
    + ); + }); + }; + return (
    + ); + } + return ( +
    + + {!closedProjects.has(p.id) && collectionsOf(list)} +
    + ); + }) + : collectionsOf(eps)} + {!eps.length && !multi && (
    {q ? (

    No endpoints match “{q}”.