diff --git a/README.md b/README.md
index 441eda8..b14211e 100644
--- a/README.md
+++ b/README.md
@@ -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
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
@@ -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
diff --git a/apps/cli/package.json b/apps/cli/package.json
index e72575a..26f3493 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -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" }
}
diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts
index 5f806dc..f3dc97b 100644
--- a/apps/cli/src/index.ts
+++ b/apps/cli/src/index.ts
@@ -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";
@@ -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." } };
@@ -482,4 +483,17 @@ async function main(): Promise {
}
}
-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();
diff --git a/apps/mcp-server/package.json b/apps/mcp-server/package.json
index a850344..2559c6d 100644
--- a/apps/mcp-server/package.json
+++ b/apps/mcp-server/package.json
@@ -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"
},
diff --git a/apps/mcp-server/src/bootstrap.test.ts b/apps/mcp-server/src/bootstrap.test.ts
new file mode 100644
index 0000000..f3eb8ea
--- /dev/null
+++ b/apps/mcp-server/src/bootstrap.test.ts
@@ -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 {
+ 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")]
+ });
+ });
+});
diff --git a/apps/mcp-server/src/bootstrap.ts b/apps/mcp-server/src/bootstrap.ts
index 6e835ee..5ec4cf8 100644
--- a/apps/mcp-server/src/bootstrap.ts
+++ b/apps/mcp-server/src/bootstrap.ts
@@ -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
@@ -43,12 +40,50 @@ async function ensureIdentity(directory: string): Promise {
});
}
-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 {
+async function waitForDaemon(directory: string, spawnFailure: () => string | undefined, timeoutMs = 10_000): Promise {
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
while (Date.now() < deadline) {
@@ -56,10 +91,14 @@ async function waitForDaemon(directory: string, timeoutMs = 10_000): Promise 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}`);
}
/**
@@ -75,7 +114,6 @@ export async function ensureDaemonRunning(directory: string): Promise=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",
@@ -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"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f83037c..9af5d23 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -15,6 +15,37 @@ overrides:
importers:
.:
+ dependencies:
+ '@modelcontextprotocol/sdk':
+ specifier: ^1.30.0
+ version: 1.30.0(supports-color@7.2.0)(zod@3.25.76)
+ '@supabase/supabase-js':
+ specifier: ^2.58.0
+ version: 2.111.0
+ chokidar:
+ specifier: ^4.0.3
+ version: 4.0.3
+ commander:
+ specifier: ^15.0.0
+ version: 15.0.0
+ minimatch:
+ specifier: ^10.2.6
+ version: 10.2.6
+ typescript:
+ specifier: ^5.8.3
+ version: 5.9.3
+ ws:
+ specifier: ^8.21.1
+ version: 8.21.1
+ yaml:
+ specifier: ^2.9.0
+ version: 2.9.0
+ zod:
+ specifier: ^3.25.76
+ version: 3.25.76
+ zod-to-json-schema:
+ specifier: ^3.25.2
+ version: 3.25.2(zod@3.25.76)
devDependencies:
'@types/jsdom':
specifier: ^28.0.3
@@ -25,6 +56,9 @@ importers:
'@vitest/coverage-v8':
specifier: ^3.2.4
version: 3.2.7(supports-color@7.2.0)(vitest@3.2.7(@types/node@24.13.3)(happy-dom@20.11.1)(jsdom@30.0.1)(supports-color@7.2.0)(tsx@4.23.1)(yaml@2.9.0))
+ esbuild:
+ specifier: ^0.28.1
+ version: 0.28.1
happy-dom:
specifier: ^20.11.1
version: 20.11.1
@@ -34,9 +68,6 @@ importers:
tsx:
specifier: ^4.19.3
version: 4.23.1
- typescript:
- specifier: ^5.8.3
- version: 5.9.3
vitest:
specifier: ^3.2.4
version: 3.2.7(@types/node@24.13.3)(happy-dom@20.11.1)(jsdom@30.0.1)(supports-color@7.2.0)(tsx@4.23.1)(yaml@2.9.0)
@@ -1075,9 +1106,6 @@ packages:
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
- jose@6.2.4:
- resolution: {integrity: sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==}
-
jose@6.2.7:
resolution: {integrity: sha512-hq1OB1bALKfydZNoViyg6hPVGV4i93ny9Op+n4zP5RSf7SCZEXa/TsG2O3IEr7+WlHRTPnpqDmHfMH6qXAD60w==}
@@ -1155,10 +1183,6 @@ packages:
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
engines: {node: '>=18'}
- minimatch@10.2.5:
- resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
- engines: {node: 18 || 20 || >=22}
-
minimatch@10.2.6:
resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
engines: {node: 18 || 20 || >=22}
@@ -1850,7 +1874,7 @@ snapshots:
express: 5.2.1(supports-color@7.2.0)
express-rate-limit: 8.6.1(express@5.2.1(supports-color@7.2.0))(supports-color@7.2.0)
hono: 4.12.34
- jose: 6.2.4
+ jose: 6.2.7
json-schema-typed: 8.0.2
pkce-challenge: 5.0.1
raw-body: 3.0.2
@@ -2475,8 +2499,6 @@ snapshots:
optionalDependencies:
'@pkgjs/parseargs': 0.11.0
- jose@6.2.4: {}
-
jose@6.2.7: {}
js-tokens@10.0.0: {}
@@ -2562,10 +2584,6 @@ snapshots:
dependencies:
mime-db: 1.54.0
- minimatch@10.2.5:
- dependencies:
- brace-expansion: 5.0.9
-
minimatch@10.2.6:
dependencies:
brace-expansion: 5.0.9
@@ -2853,7 +2871,7 @@ snapshots:
dependencies:
'@istanbuljs/schema': 0.1.6
glob: 10.5.0
- minimatch: 10.2.5
+ minimatch: 10.2.6
tinybench@2.9.0: {}
diff --git a/scripts/build.mjs b/scripts/build.mjs
new file mode 100644
index 0000000..e536791
--- /dev/null
+++ b/scripts/build.mjs
@@ -0,0 +1,69 @@
+// Bundles the three shipped entrypoints into dist/ with esbuild.
+//
+// Why bundle instead of emitting per-package JavaScript: apps/cli and apps/mcp-server
+// import the daemon by relative path (`../../daemon/src/client.js`), which escapes the
+// package root an npm tarball is rooted at. esbuild resolves those at build time, so one
+// published package needs no import rewriting and no version lockstep across the nine
+// workspace packages -- none of which have external consumers.
+import { chmod, rm } from "node:fs/promises";
+import { build } from "esbuild";
+
+// Real npm packages the bundles require at runtime. These stay external and are declared
+// as `dependencies` of the published package; everything else (the @crosscode/* workspace
+// packages) is inlined. Keep this list and the root manifest's `dependencies` in sync --
+// assertNothingVendored below fails the build if a dependency is missing from here.
+const EXTERNAL = [
+ "@modelcontextprotocol/sdk",
+ "@modelcontextprotocol/sdk/*",
+ "@supabase/supabase-js",
+ "chokidar",
+ "commander",
+ "minimatch",
+ "typescript",
+ "ws",
+ "yaml",
+ "zod",
+ "zod-to-json-schema"
+];
+
+const ENTRYPOINTS = [
+ // `crosscode` bin.
+ { in: "apps/cli/src/index.ts", out: "cli" },
+ // `crosscode-mcp` bin.
+ { in: "apps/mcp-server/src/main.ts", out: "mcp" },
+ // Not a bin: spawned by the MCP bootstrap, which locates it next to its own bundle.
+ { in: "apps/daemon/src/main.ts", out: "daemon" }
+];
+
+/**
+ * Fails the build if anything from node_modules got inlined. A dependency that is bundled
+ * rather than declared works in the monorepo and breaks only once the tarball is installed
+ * somewhere else, which is exactly the class of failure this package has to stop shipping.
+ */
+function assertNothingVendored(metafile) {
+ const vendored = Object.keys(metafile.inputs).filter((input) => input.includes("node_modules"));
+ if (vendored.length === 0) return;
+ const unique = [...new Set(vendored.map((input) => input.replace(/^.*node_modules\//, "").split("/").slice(0, 2).join("/")))];
+ throw new Error(`Bundled ${vendored.length} file(s) from node_modules. Add to EXTERNAL and to the root manifest's dependencies: ${unique.join(", ")}`);
+}
+
+await rm("dist", { recursive: true, force: true });
+
+const result = await build({
+ entryPoints: ENTRYPOINTS,
+ outdir: "dist",
+ bundle: true,
+ platform: "node",
+ target: "node24",
+ format: "esm",
+ sourcemap: true,
+ external: EXTERNAL,
+ metafile: true,
+ logLevel: "info"
+});
+
+assertNothingVendored(result.metafile);
+
+// npm sets the exec bit on `bin` targets at install time, but not for `node dist/cli.js`
+// straight out of a build, and not for the daemon, which is never a bin.
+for (const entry of ENTRYPOINTS) await chmod(`dist/${entry.out}.js`, 0o755);