Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
24
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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 |
Expand All @@ -57,6 +80,8 @@ apilot check --env dev --markdown "$GITHUB_STEP_SUMMARY"
apilot setup-ai # skills for editors without the extension
```

Add `--project <id>` 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:
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
148 changes: 107 additions & 41 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_<name> environment variables.
*
* A folder can hold several Apilot projects. Every command accepts
* `--project <id>` 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");
Expand All @@ -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<A extends unknown[]>(fn: (...args: A) => Promise<void> | 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)}`);
Expand All @@ -47,33 +69,46 @@ program
.command("init")
.description("Create a starter .apilot/ folder")
.option("-n, --name <name>", "project name")
.action(action((opts: { name?: string }) => {
ws.init(opts.name);
console.log(`✓ Apilot project ready at ${ws.apilotDir}`);
.option("-p, --project <id>", "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 <id>", "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 <endpointId>")
.description("Run one endpoint and save the (redacted) response")
.option("-e, --env <name>", "environment")
.action(action(async (id: string, opts: { env?: string }) => {
const r = await ws.run(id, opts.env);
.option("-p, --project <id>", "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}`);
Expand All @@ -87,28 +122,41 @@ 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 <name>", "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 <id>", "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
.command("diff <endpointId>")
.description("Compare responses (default: baseline → latest)")
.option("--from <snapshotId>")
.option("--to <snapshotId>")
.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 <id>", "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}`);
Expand All @@ -119,15 +167,19 @@ program
program
.command("baseline <endpointId> [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 <id>", "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 <endpointId>")
.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 <id>", "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
Expand All @@ -136,8 +188,10 @@ program
.option("-l, --lang <language>", "target language", "dart")
.option("-o, --out <dir>", "output directory (default: apilot.yaml codegen setting)")
.option("--flavor <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 <id>", "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)`);
Expand All @@ -148,10 +202,12 @@ program
.description("Import endpoints: source = curl | postman | openapi")
.option("-c, --collection <name>", "target collection")
.option("-e, --env <name>", "environment for imported variables")
.action(action(async (source: string, file: string, opts: { collection?: string; env?: string }) => {
.option("-p, --project <id>", "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}`);
}));
Expand All @@ -160,18 +216,28 @@ program
.command("check")
.description("CI: run everything; fail on errors or breaking changes vs baseline")
.option("-e, --env <name>", "environment")
.option("-p, --project <id>", "only this project")
.option("--markdown <file>", "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;
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Loading
Loading