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
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -385,13 +385,32 @@ Crosscode never stages, unstages, commits, pushes, force-pushes, resets, rebases
## Development and verification

```bash
pnpm build # tsc --noEmit, strict
pnpm build # tsc --noEmit under strict, then esbuild-bundles dist/
pnpm test # unit + local-daemon suites
pnpm test:postgres # PostgreSQL suites, serialized; needs CROSSCODE_TEST_DATABASE_URL
pnpm docs:build # regenerates the docs site from docs/*.md
pnpm audit --audit-level high
```

### Packaging

`pnpm build` bundles three entrypoints into `dist/` with esbuild — `cli.js` (the
`crosscode` bin), `mcp.js` (the `crosscode-mcp` bin), and `daemon.js`, which is not a bin
but is spawned by the MCP server's bootstrap from wherever it was installed. The
`@crosscode/*` workspace packages are inlined; the ten real npm dependencies stay external
and are declared on the root manifest. `scripts/build.mjs` fails the build if anything from
`node_modules` gets inlined, which is what keeps that list honest.

The root package is the published one. To check the tarball before publishing:

```bash
npm pack # inspect contents; dist/ + README + LICENSE only
npm i -g ./crosscode-*.tgz # or --prefix <dir> to keep it out of your global bin
cd $(mktemp -d) && git init -q . && crosscode init --json && crosscode status --json
```

`apps/service` is deliberately not part of this package: it deploys as a container.

`pnpm test` skips the PostgreSQL suites unless `CROSSCODE_TEST_DATABASE_URL` is set, and
they should be run through `pnpm test:postgres` rather than by setting that variable for
`pnpm test`: they share one database, and running them alongside parallel test files lets
Expand All @@ -415,7 +434,7 @@ For the implementation plan and current milestone ledger, see [BUILD_INSTRUCTION
- Billing has no payment provider behind it yet (see BUILD_INSTRUCTIONS.md Phase 10). The limits themselves are enforced: seat caps are checked inside the transaction that adds a member, and the autonomy tier a plan unlocks is checked on the write path, both answering `402` rather than `403` so a client can tell "out of seats" from "not allowed". The semantic-review call counter is deliberately not metered — review is delegated to your own already-connected MCP agent and never leaves your machine, so there is no per-call cost to bill and `GET /v1/workspace/billing` correctly reports zero calls used.
- `pnpm test` skips the PostgreSQL integration suites unless `CROSSCODE_TEST_DATABASE_URL` is set, so a local run leaves the service's store, pairing, and reconnect paths unexercised. CI sets it; to run them locally use `pnpm test:postgres`.
- There is no linter or formatter configured. `pnpm build` (`tsc --noEmit`) under `strict` is the only static gate.
- Deliberately not published to npm or any editor marketplace — the supported surface is the daemon + MCP server, run from a cloned checkout via `pnpm install` and `tsx` (see `docs/install-prompt.md`).
- Not on npm yet. The `crosscode` package builds, packs, and installs — `npm pack` produces a tarball whose `crosscode` and `crosscode-mcp` binaries work outside this repo on nothing but Node 24 — but it has never been published, so the documented install path is still a cloned checkout run via `pnpm install` and `tsx` (see `docs/install-prompt.md`). Publishing is one `npm publish` away; `docs/install-prompt.md`, `docs/mcp-clients.md`, and the marketing site's install snippet all need updating to the npm path at the same time. There is no editor marketplace extension.

## Contributing

Expand Down
1 change: 0 additions & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,5 @@
"name": "@crosscode/cli",
"private": true,
"type": "module",
"bin": { "crosscode": "./src/index.ts" },
"dependencies": { "@crosscode/daemon": "workspace:*", "@crosscode/git": "workspace:*", "@crosscode/protocol": "workspace:*", "@crosscode/service": "workspace:*", "commander": "^15.0.0" }
}
18 changes: 16 additions & 2 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env node
import { randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
import { realpathSync } from "node:fs";
import { createInterface } from "node:readline/promises";
import { pathToFileURL } from "node:url";
import { Command, CommanderError } from "commander";
Expand Down Expand Up @@ -461,7 +462,7 @@ function formatError(error: unknown): { error: { code: string; message: string;
// The browser-login errors already carry the frozen contract's codes and their own hints.
if (error instanceof BrowserLoginError) return { error: { code: error.code, message: error.message, hint: error.hint } };
if (error instanceof DaemonUnavailableError) {
return { error: { code: error.code, message: error.message, hint: "Run `crosscode init` if this checkout has no configuration, then start the daemon with `pnpm daemon` (or make one MCP tool call, which starts it for you)." } };
return { error: { code: error.code, message: error.message, hint: "Run `crosscode init` if this checkout has no configuration, then start the daemon by making one MCP tool call, which starts it for you." } };
}
const message = error instanceof Error ? error.message : "Command failed";
if (message === "Unknown command") return { error: { code: "UNKNOWN_COMMAND", message, hint: "Run `crosscode commands --json` to see available commands." } };
Expand All @@ -482,4 +483,17 @@ async function main(): Promise<void> {
}
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) void main();
// realpath, not argv[1] as given: npm installs a `bin` as a symlink into its bin directory,
// so argv[1] is that symlink while import.meta.url is the resolved module. Comparing them
// raw makes the installed `crosscode` binary exit silently having done nothing.
function isMainModule(): boolean {
const invoked = process.argv[1];
if (!invoked) return false;
try {
return import.meta.url === pathToFileURL(realpathSync(invoked)).href;
} catch {
return import.meta.url === pathToFileURL(invoked).href;
}
}

if (isMainModule()) void main();
1 change: 0 additions & 1 deletion apps/mcp-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
"name": "@crosscode/mcp-server",
"private": true,
"type": "module",
"bin": { "crosscode-mcp": "./src/main.ts" },
"scripts": {
"generate:docs": "tsx src/generate-tool-docs.ts"
},
Expand Down
59 changes: 59 additions & 0 deletions apps/mcp-server/src/bootstrap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { resolveDaemonLaunch } from "./bootstrap.js";

const directories: string[] = [];

async function tempDir(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), "crosscode-launch-"));
directories.push(directory);
return directory;
}

afterEach(async () => {
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
});

describe("resolveDaemonLaunch", () => {
it("runs the bundled daemon with the current node binary when installed from npm", async () => {
const dist = await tempDir();
const bundled = join(dist, "daemon.js");
await writeFile(bundled, "");

expect(resolveDaemonLaunch(dist)).toEqual({ command: process.execPath, args: [bundled] });
});

it("falls back to tsx and the daemon source in a monorepo clone", async () => {
const repoRoot = await tempDir();
await mkdir(join(repoRoot, "apps", "daemon", "src"), { recursive: true });
await mkdir(join(repoRoot, "apps", "mcp-server", "src"), { recursive: true });
await mkdir(join(repoRoot, "node_modules", ".bin"), { recursive: true });
await writeFile(join(repoRoot, "apps", "daemon", "src", "main.ts"), "");
await writeFile(join(repoRoot, "node_modules", ".bin", "tsx"), "");

expect(resolveDaemonLaunch(join(repoRoot, "apps", "mcp-server", "src"))).toEqual({
command: join(repoRoot, "node_modules", ".bin", "tsx"),
args: [join(repoRoot, "apps", "daemon", "src", "main.ts")]
});
});

it("names both candidate paths when neither layout is present", async () => {
const directory = await tempDir();

expect(() => resolveDaemonLaunch(directory)).toThrow(/no bundled daemon at .*daemon\.js.*main\.ts/s);
});

// The default argument is what actually runs in production; a clone has to resolve
// without being told where it is.
it("resolves this checkout with no explicit module directory", () => {
const repoRoot = fileURLToPath(new URL("../../../", import.meta.url));

expect(resolveDaemonLaunch()).toEqual({
command: join(repoRoot, "node_modules", ".bin", "tsx"),
args: [join(repoRoot, "apps", "daemon", "src", "main.ts")]
});
});
});
58 changes: 48 additions & 10 deletions apps/mcp-server/src/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import { randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { openInBrowser } from "../../daemon/src/browser-login.js";
import { readDaemonConfig, writeDaemonConfig } from "../../daemon/src/runtime.js";
import { DaemonClient } from "../../daemon/src/client.js";

const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const tsxBin = join(repoRoot, "node_modules", ".bin", "tsx");
const daemonMain = join(repoRoot, "apps", "daemon", "src", "main.ts");

const LOGIN_HINT = "No Crosscode session found for this directory; run `crosscode login` first, then retry.";

// Best-effort: opens the website's sign-in/sign-up page in the user's default browser the
Expand Down Expand Up @@ -43,23 +40,65 @@ async function ensureIdentity(directory: string): Promise<void> {
});
}

function spawnDaemon(directory: string): void {
const child = spawn(tsxBin, [daemonMain, "--directory", directory], { detached: true, stdio: "ignore" });
/**
* Works out how to launch the daemon for however this server was installed, and says so
* explicitly rather than guessing at a monorepo layout that only exists in a clone.
*
* Installed from npm, this module is `dist/mcp.js` and the bundled daemon is `dist/daemon.js`
* beside it, run by the same Node binary that is running us. In a clone the daemon is still
* TypeScript source that only tsx can execute, so `pnpm mcp` keeps working. Both candidates
* are checked for existence, so a layout that does not match produces a message naming the
* paths that were tried instead of an ENOENT from a path nobody ever verified.
*/
export function resolveDaemonLaunch(moduleDirectory = dirname(fileURLToPath(import.meta.url))): { command: string; args: string[] } {
const bundled = join(moduleDirectory, "daemon.js");
if (existsSync(bundled)) return { command: process.execPath, args: [bundled] };

const repoRoot = resolve(moduleDirectory, "../../..");
const daemonSource = join(repoRoot, "apps", "daemon", "src", "main.ts");
const tsxBin = join(repoRoot, "node_modules", ".bin", "tsx");
if (existsSync(daemonSource) && existsSync(tsxBin)) return { command: tsxBin, args: [daemonSource] };

throw new Error(`Cannot locate the Crosscode daemon: no bundled daemon at ${bundled}, and no ${daemonSource} runnable by ${tsxBin}`);
}

/**
* Starts the daemon detached and returns a getter for whatever went wrong, if anything.
* `detached` + `stdio: "ignore"` is what keeps the daemon alive past this process, but it
* also means a failed exec is completely silent -- the MCP client would otherwise see
* `DAEMON_UNAVAILABLE` forever with no thread to pull on.
*/
function spawnDaemon(directory: string): () => string | undefined {
const { command, args } = resolveDaemonLaunch();
let failure: string | undefined;
const child = spawn(command, [...args, "--directory", directory], { detached: true, stdio: "ignore" });
child.once("error", (error) => {
failure = `could not run \`${command}\`: ${error.message}`;
});
child.once("exit", (code, signal) => {
if (code) failure = `\`${command}\` exited with code ${code}`;
else if (signal) failure = `\`${command}\` was killed by ${signal}`;
});
child.unref();
return () => failure;
}

async function waitForDaemon(directory: string, timeoutMs = 10_000): Promise<DaemonClient> {
async function waitForDaemon(directory: string, spawnFailure: () => string | undefined, timeoutMs = 10_000): Promise<DaemonClient> {
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
while (Date.now() < deadline) {
try {
return await DaemonClient.connect(directory);
} catch (error) {
lastError = error;
// An exec that failed outright is never going to succeed on the next poll; report it
// now instead of making the caller wait out the full timeout for a worse message.
if (spawnFailure()) break;
await new Promise((resolveDelay) => setTimeout(resolveDelay, 250));
}
}
throw new Error(`Crosscode daemon did not become ready in time: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
const reason = spawnFailure() ?? (lastError instanceof Error ? lastError.message : String(lastError));
throw new Error(`Crosscode daemon did not become ready: ${reason}`);
}

/**
Expand All @@ -75,7 +114,6 @@ export async function ensureDaemonRunning(directory: string): Promise<DaemonClie
return await DaemonClient.connect(directory);
} catch {
await ensureIdentity(directory);
spawnDaemon(directory);
return waitForDaemon(directory);
return waitForDaemon(directory, spawnDaemon(directory));
}
}
31 changes: 28 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
{
"name": "crosscode",
"private": true,
"version": "0.1.0",
"description": "Local-first coordination layer that lets several people and coding agents work in the same repository at once, reviewing each other's edits as proposals instead of overwriting them",
"license": "MIT",
"repository": { "type": "git", "url": "git+https://github.com/amsultan2010/crosscode.git" },
"homepage": "https://github.com/amsultan2010/crosscode#readme",
"bugs": { "url": "https://github.com/amsultan2010/crosscode/issues" },
"keywords": ["ai", "agents", "coding-agents", "mcp", "git", "collaboration", "coordination"],
"type": "module",
"bin": {
"crosscode": "dist/cli.js",
"crosscode-mcp": "dist/mcp.js"
},
"files": ["dist"],
"packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c",
"engines": {
"node": ">=24"
},
"scripts": {
"dev": "pnpm --filter @crosscode/docs-site dev",
"build": "tsc --noEmit",
"build": "tsc --noEmit && node scripts/build.mjs",
"prepublishOnly": "pnpm build",
"test": "vitest run --coverage",
"test:postgres": "node -e \"if (!process.env.CROSSCODE_TEST_DATABASE_URL) { console.error('CROSSCODE_TEST_DATABASE_URL is required'); process.exit(1) }\" && vitest run --no-file-parallelism apps/service/src/store.integration.test.ts apps/service/src/pairing.integration.test.ts apps/daemon/src/reconnect.integration.test.ts apps/daemon/src/live-coordination.integration.test.ts",
"test:watch": "vitest",
Expand All @@ -22,14 +35,26 @@
"docs:build": "pnpm --filter @crosscode/docs-site build",
"docs:preview": "pnpm --filter @crosscode/docs-site preview"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
"@supabase/supabase-js": "^2.58.0",
"chokidar": "^4.0.3",
"commander": "^15.0.0",
"minimatch": "^10.2.6",
"typescript": "^5.8.3",
"ws": "^8.21.1",
"yaml": "^2.9.0",
"zod": "^3.25.76",
"zod-to-json-schema": "^3.25.2"
},
"devDependencies": {
"@types/jsdom": "^28.0.3",
"@types/node": "^24.0.0",
"@vitest/coverage-v8": "^3.2.4",
"esbuild": "^0.28.1",
"happy-dom": "^20.11.1",
"jsdom": "^30.0.1",
"tsx": "^4.19.3",
"typescript": "^5.8.3",
"vitest": "^3.2.4"
}
}
Loading
Loading