From 1a72f08108ac7728af9fc3368585d8c4faee41d9 Mon Sep 17 00:00:00 2001 From: Abdullah Sultan Date: Tue, 4 Aug 2026 01:13:49 -0500 Subject: [PATCH] feat: ship an installable crosscode npm package Nothing in this repo compiled: the root `build` script was `tsc --noEmit`, every package was private, and both declared `bin` entries pointed at TypeScript sources that plain Node cannot execute. Bundle three entrypoints with esbuild instead -- `crosscode`, `crosscode-mcp`, and the daemon the MCP server spawns -- and publish one package from the root manifest. Bundling also sidesteps the cross-package relative imports (`../../daemon/src/client.js`) that escape any per-package tarball root, so no import rewriting is needed to ship. scripts/build.mjs fails the build if anything from node_modules is inlined: a bundled-but-undeclared dependency works in the monorepo and breaks only once the tarball is installed somewhere else. Also fixes two things that only fail after install: - The MCP bootstrap hardcoded the monorepo layout (`node_modules/.bin/tsx` plus `apps/daemon/src/main.ts`) and spawned it with `stdio: "ignore"`, so in an installed package it failed silently and the client saw DAEMON_UNAVAILABLE forever. resolveDaemonLaunch now prefers the bundled daemon run by process.execPath, keeps an existence-checked monorepo fallback for `pnpm mcp`, and surfaces a message naming what was tried. - The CLI's main-module guard compared import.meta.url against argv[1] as given. npm installs a bin as a symlink, so the installed `crosscode` binary parsed nothing and exited silently. Compare against the realpath. Verified from a tarball installed outside this repo on Node 24 alone: --help, init, status, and commands all work; an MCP initialize + tools/list handshake returns 22 tools; and the bootstrap starts a real daemon whose eventSequence advances after an edit. Publishing is deliberately left to the owner -- the docs still describe the clone-and-tsx install path, which stays correct until the package is live. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 23 ++++++++- apps/cli/package.json | 1 - apps/cli/src/index.ts | 18 ++++++- apps/mcp-server/package.json | 1 - apps/mcp-server/src/bootstrap.test.ts | 59 +++++++++++++++++++++++ apps/mcp-server/src/bootstrap.ts | 58 ++++++++++++++++++---- package.json | 31 ++++++++++-- pnpm-lock.yaml | 54 ++++++++++++++------- scripts/build.mjs | 69 +++++++++++++++++++++++++++ 9 files changed, 277 insertions(+), 37 deletions(-) create mode 100644 apps/mcp-server/src/bootstrap.test.ts create mode 100644 scripts/build.mjs 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);