diff --git a/.github/file-filters.yml b/.github/file-filters.yml index 19d39d3aea..03b889413d 100644 --- a/.github/file-filters.yml +++ b/.github/file-filters.yml @@ -78,6 +78,18 @@ registry_sync: # own tsconfig. Both regressions it has caught (the CLI entry point, and an # unlisted `vue-demi` build script that broke every scaffold's first install) came # from packages/registry changes, not from the templates. +# Worker size budget. Listed separately from `packages` so the gate cannot be +# switched off by editing the gate: a PR touching only the checker, the +# committed baseline, or the job itself still runs it. +worker_size: + - "scripts/check-worker-size.js" + - "worker-size.json" + - ".github/workflows/test.yml" + # This file too, or the hole reopens one level up: a PR that edits the + # filter itself would not match it, the gate would skip, and + # `test-required-check` passes on skipped jobs. + - ".github/file-filters.yml" + templates: - "templates/**" - "registry/**" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dae81db532..d62f18265b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,6 +29,7 @@ "e2e": "${{ steps.changes.outputs.e2e }}" "codecov": "${{ steps.changes.outputs.codecov }}" "templates": "${{ steps.changes.outputs.templates }}" + "worker_size": "${{ steps.changes.outputs.worker_size }}" "steps": - "name": "Harden Runner" "uses": "step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411" # v2.19.4 @@ -307,11 +308,55 @@ "retention-days": 7 "if-no-files-found": "ignore" + # Weighs the Worker a `templates/standalone` app deploys against the committed + # ceiling in `worker-size.json`. Nothing else in CI measures bytes, and the + # cost of noticing late is a user's deploy rejected by Cloudflare for a + # dependency this repo added weeks earlier. + # + # Its own job, NOT the root `postinstall`: a failing postinstall gate turns + # every job red in its setup step and the cause is invisible in the job that + # reports it. + "worker-size": + "name": "Worker size budget" + "if": "needs.files-changed.outputs.packages == 'true' || needs.files-changed.outputs.templates == 'true' || needs.files-changed.outputs.worker_size == 'true'" + "needs": "files-changed" + "runs-on": "ubuntu-latest" + "timeout-minutes": 30 + "steps": + - "name": "Harden Runner" + "uses": "step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411" # v2.19.4 + "with": + "egress-policy": "audit" + + - "name": "Git checkout" + "uses": "anolilab/workflows/step/checkout-with-retry@6c16895f4c3b5273b373656988505d1a8264e78b" # main + + - "name": "Setup resources and environment" + "uses": "anolilab/workflows/step/node@acba5fb15ac98ad23dec1ad233e40e1bb79c1dae" # v19.0.4 + "with": + "node-version": "22.15" + "cache-prefix": "worker-size" + "install-node-gyp": "true" + "skip-playwright": "true" + "enable-nx-cache": "false" + "run-npm-audit": "false" + "run-signatures-audit": "false" + + # `build:packages:prod`, not `build:packages`: the reference app + # bundles the workspace `dist/` directories, and users install the + # production build. The development build measures ~25% heavier — a + # number nobody deploys, and a baseline nobody could reproduce. + - "name": "Build packages (production artifacts)" + "run": "pnpm run build:packages:prod" + + - "name": "Weigh the reference Worker" + "run": "pnpm run worker-size:check" + # Single required check: green when every dependent job passed or was # skipped (so a no-package-change PR with all jobs skipped still passes). "test-required-check": "name": "Check Test Run" - "needs": ["files-changed", "test", "test-workerd", "e2e", "templates"] + "needs": ["files-changed", "test", "test-workerd", "e2e", "templates", "worker-size"] "if": "always()" "runs-on": "ubuntu-latest" "timeout-minutes": 5 diff --git a/api-snapshots/cli.api.md b/api-snapshots/cli.api.md index 7e15308b38..98459600fd 100644 --- a/api-snapshots/cli.api.md +++ b/api-snapshots/cli.api.md @@ -115,6 +115,9 @@ interface DeployCommandOptions { env?: string; fetchImpl?: FetchLike; format?: string; + healthCheck?: boolean; + healthFetch?: HealthFetch; + healthSleep?: (ms: number) => Promise; interactive?: boolean; logger: Logger; migrate?: boolean; @@ -140,8 +143,14 @@ interface DeployCommandOptions { ```ts interface DeployCommandResult { code: number; + deployment?: DeployedIdentity; descriptor: SpawnDescriptor | undefined; error?: string; + healthCheck?: { + error?: string; + ok: boolean; + url: string; + }; mintedSecretsFile?: string; schemaDrift?: { blocked: boolean; @@ -154,6 +163,19 @@ interface DeployCommandResult { } ``` +### `DeployedIdentity` (interface) + +```ts +interface DeployedIdentity { + deployedAt: string; + dryRun: boolean; + env?: string; + preview: boolean; + url?: string; + workerName?: string; +} +``` + ### `DevCommandOptions` (interface) ```ts diff --git a/api-snapshots/mcp.api.md b/api-snapshots/mcp.api.md index 96546ec3d2..c7a62ee09c 100644 --- a/api-snapshots/mcp.api.md +++ b/api-snapshots/mcp.api.md @@ -125,6 +125,12 @@ interface McpTool { const NO_DEPLOYMENT_MESSAGE = "no Lunora dev server is running for this project — start one with `lunora dev`, then call this tool again (call lunora_dev_status to check)."; ``` +### `OBSERVABILITY_TOOL_DEFINITIONS` (const) + +```ts +const OBSERVABILITY_TOOL_DEFINITIONS: ReadonlyArray; +``` + ### `PaidMcpChargeConfig` (type) ```ts @@ -186,6 +192,7 @@ interface ToolDefinition { description: string; inputSchema: ToolInputSchema; name: string; + outputSchema?: ToolInputSchema; } ``` @@ -214,6 +221,7 @@ interface ToolResult { type: "text"; }[]; isError?: boolean; + structuredContent?: Record; } ``` @@ -238,7 +246,7 @@ const callAgentTool: (client: LunoraClient, name: string, input: Record, allowWrites?: boolean) => Promise; +const callTool: (client: LunoraClient, name: string, input: Record, allowWrites?: boolean, hasAdminToken?: boolean) => Promise; ``` ### `connectLocalStdio` (const) @@ -304,7 +312,7 @@ const serveStateless: (server: Server, request: Request, options?: HandleRequest ### `toolDefinitions` (const) ```ts -const toolDefinitions: (allowWrites: boolean) => ReadonlyArray; +const toolDefinitions: (allowWrites: boolean, hasAdminToken?: boolean) => ReadonlyArray; ``` ## `@lunora/mcp/docs` @@ -470,6 +478,7 @@ interface ToolDefinition { description: string; inputSchema: ToolInputSchema; name: string; + outputSchema?: ToolInputSchema; } ``` @@ -492,6 +501,7 @@ interface ToolResult { type: "text"; }[]; isError?: boolean; + structuredContent?: Record; } ``` diff --git a/apps/docs/src/content/docs/deployment.mdx b/apps/docs/src/content/docs/deployment.mdx index ccf869c98a..ea6cf7a111 100644 --- a/apps/docs/src/content/docs/deployment.mdx +++ b/apps/docs/src/content/docs/deployment.mdx @@ -145,6 +145,43 @@ CI should run `lunora verify` (or `lunora prepare`) first so codegen drift and a stale `_generated/` are a build failure, not a deploy that silently uses old types. +## Worker size + +Cloudflare caps a Worker script +[in two places](https://developers.cloudflare.com/workers/platform/limits/): +**3 MB on Workers Free and 10 MB on Workers Paid after gzip compression**, and +**64 MB before compression** on both plans. Both are enforced at upload, so an +over-budget bundle is a rejected deploy rather than a slow one — and a bundle +that compresses unusually well (a large generated table, say) can sit under the +gzip limit while breaching the raw one. Check both numbers. + +`lunora build` weighs what it wrote: + +```bash +pnpm lunora build +# … bundle: 1684.9 KiB raw, 412.9 KiB gzipped across 1 file(s) + +pnpm lunora build --format json | jq .bundle +# { "files": 1, "gzipBytes": 422840, "rawBytes": 1725313 } +``` + +Only the uploaded files are counted — the sourcemap and the esbuild metafile +sitting in the same out-dir are not part of the script, and counting them would +roughly triple the number. Compare `gzipBytes` against your plan's compressed +limit and `rawBytes` against the 64 MB one; together they match what +`wrangler deploy` reports as `Total Upload: … / gzip: …`. + +A starter app is around **410 KiB gzipped**, so most projects have a lot of +room. If yours is approaching the limit: + +- **Drop add-ons you no longer import.** Every `@lunora/*` add-on your Worker + entry reaches is bundled, whether or not a request ever uses it. +- **Check for a dev-only import reaching the Worker entry.** A seed script, a + test helper, or a Node-only utility imported from `lunora/` pulls its whole + dependency tree into the deployed bundle. +- **Look at what is actually heavy.** `lunora analyze` bundles the Worker and + prints the largest modules, which is usually enough to name the culprit. + ## Streaming logs Tail a deployed Worker's live logs with: diff --git a/package.json b/package.json index f344f893c5..68889f8250 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,9 @@ "test:affected:coverage": "vis affected test:coverage --query \"project!=lunora-e2e&&project!=studio&&project!=lunora-playground\"", "test:clean-machine": "./scripts/clean-machine-smoke.sh", "test:coverage": "vis run test:coverage --query \"project!=lunora-e2e&&project!=studio&&project!=lunora-playground\"", - "test:templates": "./scripts/template-build-smoke.sh" + "test:templates": "./scripts/template-build-smoke.sh", + "worker-size:check": "node scripts/check-worker-size.js", + "worker-size:update": "node scripts/check-worker-size.js --update" }, "devDependencies": { "@anolilab/commitlint-config": "catalog:prod", diff --git a/packages/cli/__tests__/commands/build.test.ts b/packages/cli/__tests__/commands/build.test.ts index c930d15ff4..82bf5e392f 100644 --- a/packages/cli/__tests__/commands/build.test.ts +++ b/packages/cli/__tests__/commands/build.test.ts @@ -1,12 +1,15 @@ -import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { gzipSync } from "node:zlib"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { BuildCommandResult } from "../../src/commands/build/handler"; import { runBuildCommand } from "../../src/commands/build/handler"; import type { Logger } from "../../src/util/logger"; +import type { Spawner } from "../../src/util/spawn"; import { createRecordingSpawner } from "../../src/util/spawn"; const here = dirname(fileURLToPath(import.meta.url)); @@ -24,22 +27,46 @@ const VALID_WRANGLER = `{ } `; -const silentLogger = (): { logger: Logger; successes: string[] } => { +const silentLogger = (): { logger: Logger; successes: string[]; warnings: string[] } => { const successes: string[] = []; + const warnings: string[] = []; return { logger: { error: () => {}, info: () => {}, success: (message) => successes.push(message), - warn: () => {}, + warn: (message) => warnings.push(message), }, successes, + warnings, }; }; +/** Worker script the fake wrangler "bundles" — big enough that gzip is a real number. */ +const SCRIPT = `export default { fetch() { return new Response(${JSON.stringify("ok".repeat(4096))}); } };\n`; + let workdir: string; +/** + * A spawner that writes what `wrangler deploy --outdir` writes: the script, its + * sourcemap, the esbuild metafile, and wrangler's explanatory README — so the + * measurement is exercised against the layout it actually has to filter. + */ +const bundlingSpawner = + (outDirectory: string): Spawner => + async (descriptor) => { + const directory = join(workdir, outDirectory); + + mkdirSync(directory, { recursive: true }); + writeFileSync(join(directory, "server.js"), SCRIPT, "utf8"); + writeFileSync(join(directory, "server.js.map"), "x".repeat(50_000), "utf8"); + writeFileSync(join(directory, "bundle-meta.json"), "y".repeat(50_000), "utf8"); + writeFileSync(join(directory, "README.md"), "wrangler wrote this\n", "utf8"); + + return { code: 0, descriptor, stderr: "", stdout: "" }; + }; + describe("lunora build", () => { beforeEach(() => { workdir = mkdtempSync(join(tmpdir(), "lunora-build-")); @@ -104,6 +131,71 @@ describe("lunora build", () => { expect(successes.join("\n")).toContain("binding manifest written to"); }); + it("weighs the bundle it wrote, counting only what Cloudflare uploads", async () => { + expect.assertions(4); + + const { logger } = silentLogger(); + + const result = await runBuildCommand({ cwd: workdir, logger, outDir: "dist-worker", spawner: bundlingSpawner("dist-worker") }); + + // The sourcemap, the metafile and wrangler's README are all in the + // out-dir and none of them ship — counting them would report a bundle + // roughly three times its real weight. + expect(result.bundle?.files).toBe(1); + expect(result.bundle?.rawBytes).toBe(Buffer.byteLength(SCRIPT)); + expect(result.bundle?.gzipBytes).toBe(gzipSync(Buffer.from(SCRIPT)).byteLength); + expect(result.bundle?.gzipBytes).toBeGreaterThan(0); + }); + + it("reports the size in the --format json document without failing on it", async () => { + expect.assertions(3); + + const { logger } = silentLogger(); + const written: string[] = []; + const spy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + written.push(String(chunk)); + + return true; + }); + + let result: BuildCommandResult; + + try { + result = await runBuildCommand({ + cwd: workdir, + format: "json", + logger, + outDir: "dist-worker", + spawner: bundlingSpawner("dist-worker"), + }); + } finally { + spy.mockRestore(); + } + + // Measuring is reporting: a size never changes the exit code. + expect(result.code).toBe(0); + + const document = JSON.parse(written.join("")) as BuildCommandResult; + + expect(written).toHaveLength(1); + expect(document.bundle?.gzipBytes).toBeGreaterThan(0); + }); + + it("says so rather than reporting zero when there is nothing to weigh", async () => { + expect.assertions(2); + + const { logger, warnings } = silentLogger(); + + // The recording spawner writes no out-dir — which is what a changed + // wrangler layout would also look like. A 0-byte bundle would read as + // the healthiest possible result, so it must not be reported at all. + const { spawner } = createRecordingSpawner(); + const result = await runBuildCommand({ cwd: workdir, logger, spawner }); + + expect(result.bundle).toBeUndefined(); + expect(warnings.join("\n")).toContain("could not weigh the bundle"); + }); + it("--emit-bindings fails rather than describing a Worker that needs nothing", async () => { expect.assertions(2); diff --git a/packages/cli/__tests__/commands/deploy.test.ts b/packages/cli/__tests__/commands/deploy.test.ts index 5ffd2bce60..9b1558ee8d 100644 --- a/packages/cli/__tests__/commands/deploy.test.ts +++ b/packages/cli/__tests__/commands/deploy.test.ts @@ -1,4 +1,4 @@ -import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -10,7 +10,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { runDeployCommand } from "../../src/commands/deploy/handler"; import type { FetchLike } from "../../src/commands/run/handler"; +import type { HealthFetch } from "../../src/util/health-probe"; import type { Logger } from "../../src/util/logger"; +import type { RecordedSpawn, Spawner } from "../../src/util/spawn"; import { createRecordingSpawner } from "../../src/util/spawn"; // A pass-through wrapper around the real `runCodegen` — every existing test in @@ -61,6 +63,33 @@ const captureStdout = async (body: () => Promise): Promise => { return captured; }; +/** What a real `wrangler deploy` prints once the Worker is live. */ +const WRANGLER_DEPLOY_OUTPUT = `Total Upload: 12.34 KiB / gzip: 4.56 KiB +Uploaded lunora-app (2.21 sec) +Deployed lunora-app triggers (0.85 sec) + https://lunora-app.acme.workers.dev +Current Version ID: 1f2e3d4c-5b6a-7089-9a0b-1c2d3e4f5a6b +`; + +/** + * A recording spawner that behaves like wrangler: when the descriptor asked for + * stdout (either capture mode), it resolves with real-looking deploy output so + * the URL parser has something to read. + */ +const deployingSpawner = (stdout: string = WRANGLER_DEPLOY_OUTPUT, exitCode = 0): { calls: RecordedSpawn[]; spawner: Spawner } => { + const calls: RecordedSpawn[] = []; + + const spawner: Spawner = (descriptor) => { + calls.push({ descriptor }); + + const captured = descriptor.captureStdout === true || descriptor.captureStdoutSilently === true; + + return Promise.resolve({ code: exitCode, stdout: captured ? stdout : undefined }); + }; + + return { calls, spawner }; +}; + const here = dirname(fileURLToPath(import.meta.url)); const fixtureRoot = join(here, "..", "..", "..", "codegen", "__tests__", "fixtures", "simple"); @@ -893,29 +922,36 @@ export const backfillNames = defineMigration({ expect(parsed.descriptor?.args).toContain("deploy"); }); - it("routes the spawned wrangler's stdout to stderr so it can't corrupt the JSON document", async () => { - // Regression: `wrangler deploy` inherits stdio; without redirection - // its progress + deployed-URL output interleaves with the JSON on - // stdout and breaks `lunora deploy --format json | jq`. - expect.assertions(2); + it("captures the spawned wrangler's stdout SILENTLY so it can't corrupt the JSON document", async () => { + // Regression: `wrangler deploy`'s progress + deployed-URL output + // must never interleave with the JSON on stdout, or + // `lunora deploy --format json | jq` breaks. json mode therefore + // captures without teeing (`captureStdoutSilently`); the plain + // `captureStdout` used in pretty mode WOULD tee, and corrupt it. + expect.assertions(5); writeFileSync(join(workdir, "wrangler.jsonc"), VALID_WRANGLER, "utf8"); - const { calls, spawner } = createRecordingSpawner(); + const { calls, spawner } = deployingSpawner(); const { logger } = silentLogger(); - await captureStdout(async () => { + const stdout = await captureStdout(async () => { await runDeployCommand({ cwd: workdir, secretLister: noRemoteSecrets, format: "json", logger, spawner }); }); - expect(calls[0]?.descriptor.stdoutToStderr).toBe(true); + expect(calls[0]?.descriptor.captureStdoutSilently).toBe(true); + expect(calls[0]?.descriptor.captureStdout).toBe(false); - // Pretty mode keeps wrangler's output inherited on stdout. - const pretty = createRecordingSpawner(); + // Exactly one JSON document, with no wrangler text mixed in. + expect(stdout).not.toContain("Total Upload"); + expect(JSON.parse(stdout)).toHaveProperty("deployment"); + + // Pretty mode tees, so the user still watches live progress. + const pretty = deployingSpawner(); await runDeployCommand({ cwd: workdir, secretLister: noRemoteSecrets, logger, spawner: pretty.spawner }); - expect(pretty.calls[0]?.descriptor.stdoutToStderr).toBe(false); + expect(pretty.calls[0]?.descriptor.captureStdout).toBe(true); }); it("routes a postcodegen script's stdout to stderr in json mode", async () => { @@ -971,6 +1007,69 @@ export const backfillNames = defineMigration({ expect(parsed.error).toBeDefined(); }); + it("reports the deployed URL in the document, so an automation never has to read it with its eyes", async () => { + expect.assertions(5); + + writeFileSync(join(workdir, "wrangler.jsonc"), VALID_WRANGLER, "utf8"); + + const { spawner } = deployingSpawner(); + const { logger } = silentLogger(); + + const stdout = await captureStdout(async () => { + await runDeployCommand({ cwd: workdir, env: undefined, secretLister: noRemoteSecrets, format: "json", logger, spawner }); + }); + + const parsed = JSON.parse(stdout) as { + deployment?: { deployedAt: string; dryRun: boolean; preview: boolean; url?: string; workerName?: string }; + }; + + expect(parsed.deployment?.url).toBe("https://lunora-app.acme.workers.dev"); + expect(parsed.deployment?.workerName).toBe("lunora-app"); + expect(parsed.deployment?.dryRun).toBe(false); + expect(parsed.deployment?.preview).toBe(false); + expect(Number.isNaN(Date.parse(parsed.deployment?.deployedAt ?? ""))).toBe(false); + }); + + it("--preview --format json reports the preview URL", async () => { + expect.assertions(3); + + writeFileSync(join(workdir, "wrangler.jsonc"), VALID_WRANGLER, "utf8"); + + const { spawner } = deployingSpawner("Worker Version ID: abc\n https://preview-abc-lunora-app.acme.workers.dev\n"); + const { logger } = silentLogger(); + + const stdout = await captureStdout(async () => { + await runDeployCommand({ cwd: workdir, secretLister: noRemoteSecrets, format: "json", logger, preview: true, spawner }); + }); + + const parsed = JSON.parse(stdout) as { deployment?: { preview: boolean; url?: string } }; + + expect(parsed.deployment?.url).toBe("https://preview-abc-lunora-app.acme.workers.dev"); + expect(parsed.deployment?.preview).toBe(true); + // A preview never becomes the checkout's recorded target. + expect(existsSync(join(workdir, ".lunora", "project.json"))).toBe(false); + }); + + it("--dry-run reports the discriminator and no URL — nothing was published", async () => { + expect.assertions(3); + + writeFileSync(join(workdir, "wrangler.jsonc"), VALID_WRANGLER, "utf8"); + + const { calls, spawner } = deployingSpawner(); + const { logger } = silentLogger(); + + const stdout = await captureStdout(async () => { + await runDeployCommand({ cwd: workdir, dryRun: true, secretLister: noRemoteSecrets, format: "json", logger, spawner }); + }); + + const parsed = JSON.parse(stdout) as { deployment?: { dryRun: boolean; url?: string } }; + + expect(parsed.deployment?.dryRun).toBe(true); + expect(parsed.deployment?.url).toBeUndefined(); + // Nothing to read → wrangler's stdout is not captured at all. + expect(calls[0]?.descriptor.captureStdoutSilently).toBe(false); + }); + it("rejects an unknown --format the same way logs does", async () => { expect.assertions(5); @@ -992,6 +1091,136 @@ export const backfillNames = defineMigration({ }); }); + describe("link capture", () => { + it("records the deployed URL after a real deploy", async () => { + expect.assertions(2); + + writeFileSync(join(workdir, "wrangler.jsonc"), VALID_WRANGLER, "utf8"); + + const { spawner } = deployingSpawner(); + const { logger } = silentLogger(); + + const result = await runDeployCommand({ cwd: workdir, secretLister: noRemoteSecrets, logger, spawner }); + const written = JSON.parse(readFileSync(join(workdir, ".lunora", "project.json"), "utf8")) as { workerUrl?: string }; + + expect(result.code).toBe(0); + expect(written.workerUrl).toBe("https://lunora-app.acme.workers.dev"); + }); + + it("--temporary reports the URL but never records it as the checkout's target", async () => { + expect.assertions(2); + + // The account is deleted in ~60 minutes; a link pointing at it + // would silently misroute `run` / `logs` / `--migrate` afterwards. + writeFileSync(join(workdir, "wrangler.jsonc"), VALID_WRANGLER, "utf8"); + + const { spawner } = deployingSpawner(); + const { logger } = silentLogger(); + + const result = await runDeployCommand({ cwd: workdir, secretLister: noRemoteSecrets, logger, spawner, temporary: true }); + + expect(result.deployment?.url).toBe("https://lunora-app.acme.workers.dev"); + expect(existsSync(join(workdir, ".lunora", "project.json"))).toBe(false); + }); + }); + + describe("--health-check", () => { + it("passes when the new version answers", async () => { + expect.assertions(4); + + writeFileSync(join(workdir, "wrangler.jsonc"), VALID_WRANGLER, "utf8"); + + const { spawner } = deployingSpawner(); + const { logger } = silentLogger(); + const healthFetch = vi.fn(async () => { + return { ok: true, status: 200 }; + }); + + const result = await runDeployCommand({ cwd: workdir, healthCheck: true, healthFetch, secretLister: noRemoteSecrets, logger, spawner }); + + expect(result.code).toBe(0); + expect(result.healthCheck?.ok).toBe(true); + // Probed at the URL THIS run published to, readiness gate first. + expect(healthFetch).toHaveBeenCalledWith("https://lunora-app.acme.workers.dev/_lunora/health/ready"); + expect(result.healthCheck?.error).toBeUndefined(); + }); + + it("fails the command when the probe never goes green, and says the deploy itself succeeded", async () => { + expect.assertions(4); + + writeFileSync(join(workdir, "wrangler.jsonc"), VALID_WRANGLER, "utf8"); + + const { spawner } = deployingSpawner(); + const { errors, logger } = silentLogger(); + const healthFetch = vi.fn(async () => { + return { ok: false, status: 503 }; + }); + + const result = await runDeployCommand({ + cwd: workdir, + healthCheck: true, + healthFetch, + healthSleep: async () => {}, + secretLister: noRemoteSecrets, + logger, + spawner, + }); + + expect(result.code).toBe(1); + expect(result.healthCheck?.error).toContain("returned HTTP 503"); + // The deploy succeeded and the probe did not — different facts. + expect(errors.join("\n")).toContain("the deploy succeeded, but the new version did not answer"); + // The identity is still reported: the version IS out there. + expect(result.deployment?.url).toBe("https://lunora-app.acme.workers.dev"); + }); + + it("is skipped (with a warning) on a dry run, which publishes nothing to probe", async () => { + expect.assertions(3); + + writeFileSync(join(workdir, "wrangler.jsonc"), VALID_WRANGLER, "utf8"); + + const { spawner } = deployingSpawner(); + const { logger, warns } = silentLogger(); + const healthFetch = vi.fn(async () => { + return { ok: true, status: 200 }; + }); + + const result = await runDeployCommand({ + cwd: workdir, + dryRun: true, + healthCheck: true, + healthFetch, + secretLister: noRemoteSecrets, + logger, + spawner, + }); + + expect(result.code).toBe(0); + expect(healthFetch).not.toHaveBeenCalled(); + expect(warns.join("\n")).toContain("--health-check skipped"); + }); + + it("refuses rather than guessing an origin when no URL can be resolved", async () => { + expect.assertions(3); + + writeFileSync(join(workdir, "wrangler.jsonc"), VALID_WRANGLER, "utf8"); + + // wrangler printed no URL (custom-route-only worker) and the + // checkout has no link → nothing safe to probe. + const { spawner } = deployingSpawner("Total Upload: 1 KiB\nDeployed lunora-app triggers\n"); + const { errors, logger } = silentLogger(); + const healthFetch = vi.fn(async () => { + return { ok: true, status: 200 }; + }); + + const result = await runDeployCommand({ cwd: workdir, healthCheck: true, healthFetch, secretLister: noRemoteSecrets, logger, spawner }); + + expect(result.code).toBe(1); + expect(healthFetch).not.toHaveBeenCalled(); + expect(errors.join("\n")).toContain("no URL to probe could be resolved"); + }); + }); + describe("missing-secret gate", () => { it("mints a missing secret, records it in .dev.vars, and never logs the value", async () => { expect.assertions(6); diff --git a/packages/cli/__tests__/commands/doctor.test.ts b/packages/cli/__tests__/commands/doctor.test.ts index 023cd19ea6..ca613021f5 100644 --- a/packages/cli/__tests__/commands/doctor.test.ts +++ b/packages/cli/__tests__/commands/doctor.test.ts @@ -1,12 +1,31 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { runDoctor } from "../../src/commands/doctor/handler"; +import { DOCTOR_CODES, runDoctor, runDoctorCommand } from "../../src/commands/doctor/handler"; import type { Logger } from "../../src/util/logger"; +/** Run async `body` while capturing everything written to `process.stdout`. */ +const captureStdout = async (body: () => Promise): Promise => { + let captured = ""; + const spy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array): boolean => { + captured += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); + + return true; + }); + + try { + await body(); + } finally { + spy.mockRestore(); + } + + return captured; +}; + const makeLogger = (): { lines: string[]; logger: Logger } => { const lines: string[] = []; const push = @@ -335,4 +354,176 @@ describe("runDoctor", () => { expect(versionFinding(result.findings)).toBeUndefined(); }); }); + + /** + * A globally-installed `lunora` shadowing the project's pinned one makes every + * other finding describe a project this CLI may be the wrong version for — + * and `checkVersionSkew` cannot see it, because the manifest it reads is + * exactly the file the shadowing binary ignores. + */ + describe("cli shadowing", () => { + /** Install `@lunora/cli` the way pnpm does: a symlink into a store directory. */ + const seedLocalCli = (): string => { + const store = join(workdir, "node_modules", ".pnpm", "@lunora+cli@1.0.0", "node_modules", "@lunora", "cli", "dist"); + + mkdirSync(store, { recursive: true }); + writeFileSync(join(store, "bin.mjs"), "// bin\n", "utf8"); + mkdirSync(join(workdir, "node_modules", "@lunora"), { recursive: true }); + symlinkSync(dirname(store), join(workdir, "node_modules", "@lunora", "cli"), "dir"); + + return join(store, "bin.mjs"); + }; + + const shadowFinding = (findings: ReadonlyArray<{ code: string; level: string }>) => findings.find((finding) => finding.code === "cli-shadowed"); + + it("stays clean when the running binary is the project's own pnpm-linked install", async () => { + expect.assertions(1); + + seed(workdir, CLEAN_WRANGLER); + + const localEntry = seedLocalCli(); + const result = await runDoctor({ cwd: workdir, executablePath: localEntry, logger: makeLogger().logger }); + + // The bin is reached through a symlinked package directory, which is + // the layout that a naive path-equality check reports as a mismatch. + expect(shadowFinding(result.findings)).toBeUndefined(); + }); + + it("warns exactly once when the running binary lives outside the project", async () => { + expect.assertions(3); + + seed(workdir, CLEAN_WRANGLER); + seedLocalCli(); + + const globalDir = mkdtempSync(join(tmpdir(), "lunora-cli-global-")); + const globalEntry = join(globalDir, "bin.mjs"); + + writeFileSync(globalEntry, "// bin\n", "utf8"); + + const result = await runDoctor({ cwd: workdir, executablePath: globalEntry, logger: makeLogger().logger }); + + rmSync(globalDir, { force: true, recursive: true }); + + expect(result.findings.filter((finding) => finding.code === "cli-shadowed")).toHaveLength(1); + expect(shadowFinding(result.findings)?.level).toBe("warn"); + // A wrong binary is never a hard failure — it is often deliberate. + expect(result.code).toBe(0); + }); + + it("skips silently when the project has no local install", async () => { + expect.assertions(1); + + seed(workdir, CLEAN_WRANGLER); + + const result = await runDoctor({ cwd: workdir, executablePath: join(tmpdir(), "somewhere", "bin.mjs"), logger: makeLogger().logger }); + + expect(shadowFinding(result.findings)).toBeUndefined(); + }); + }); + + describe("--format json", () => { + it("puts one JSON document on stdout and the human report on stderr", async () => { + expect.assertions(5); + + seed(workdir, PLACEHOLDER_WRANGLER); + + const { logger } = makeLogger(); + let stderr = ""; + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk: string | Uint8Array): boolean => { + stderr += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); + + return true; + }); + + const stdout = await captureStdout(async () => { + await runDoctorCommand({ cwd: workdir, format: "json", logger }); + }); + + stderrSpy.mockRestore(); + + const parsed = JSON.parse(stdout) as { code: number; findings: { code: string; level: string }[]; ok: boolean; summary: Record }; + + expect(parsed.ok).toBe(false); + expect(parsed.code).toBe(1); + expect(parsed.findings.some((finding) => finding.code === "d1-placeholder-id" && finding.level === "fail")).toBe(true); + expect(parsed.summary.fail).toBe(1); + // The report is still rendered — on stderr, so stdout stays pipeable. + expect(stderr).toContain("lunora doctor — project preflight"); + }); + + it("counts every level in the summary and keeps pass findings in the document", async () => { + expect.assertions(2); + + seed(workdir, CLEAN_WRANGLER); + + const stdout = await captureStdout(async () => { + await runDoctorCommand({ cwd: workdir, format: "json", logger: makeLogger().logger }); + }); + + const parsed = JSON.parse(stdout) as { findings: { level: string }[]; summary: Record<"fail" | "info" | "pass" | "warn", number> }; + + expect(parsed.summary.pass).toBeGreaterThan(0); + expect(parsed.findings.filter((finding) => finding.level === "pass")).toHaveLength(parsed.summary.pass); + }); + + it("renders the human report on the caller's logger in pretty mode", async () => { + expect.assertions(2); + + seed(workdir, CLEAN_WRANGLER); + + const { lines, logger } = makeLogger(); + + const stdout = await captureStdout(async () => { + await runDoctorCommand({ cwd: workdir, logger }); + }); + + expect(stdout).toBe(""); + expect(lines.some((line) => line.includes("lunora doctor — project preflight"))).toBe(true); + }); + + it("rejects an unknown --format the same way the other commands do", async () => { + expect.assertions(3); + + seed(workdir, CLEAN_WRANGLER); + + const { lines, logger } = makeLogger(); + + const stdout = await captureStdout(async () => { + const result = await runDoctorCommand({ cwd: workdir, format: "yaml", logger }); + + expect(result.code).toBe(1); + }); + + expect(stdout).toBe(""); + expect(lines.some((line) => line.includes('unknown --format "yaml" — expected pretty | json'))).toBe(true); + }); + }); + + /** + * The codes are the machine-readable contract, so adding or renaming one has + * to be a deliberate act rather than a side effect of editing a check. The + * docs table is the committed fixture: it is the artefact consumers read, so + * asserting against it keeps the contract and its documentation in one place + * instead of two that can drift. + */ + describe("finding codes", () => { + const DOCS_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "docs", "index.mdx"); + + it("is a sorted, duplicate-free list", () => { + expect.assertions(2); + + expect([...DOCTOR_CODES]).toStrictEqual([...DOCTOR_CODES].toSorted((left, right) => left.localeCompare(right))); + expect(new Set(DOCTOR_CODES).size).toBe(DOCTOR_CODES.length); + }); + + it("documents exactly the codes the doctor can emit", () => { + expect.assertions(1); + + const documented = [...readFileSync(DOCS_PATH, "utf8").matchAll(/^\| `(?[a-z\d-]+)` +\| +(?:fail|info|pass|warn) /gmu)].map( + (match) => match.groups?.code ?? "", + ); + + expect(documented).toStrictEqual([...DOCTOR_CODES]); + }); + }); }); diff --git a/packages/cli/__tests__/util/auto-link.test.ts b/packages/cli/__tests__/util/auto-link.test.ts index 676c0f9217..7a148f4034 100644 --- a/packages/cli/__tests__/util/auto-link.test.ts +++ b/packages/cli/__tests__/util/auto-link.test.ts @@ -8,8 +8,15 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { autoLinkFromDeployOutput, parseDeployedUrl } from "../../src/util/auto-link"; import type { Logger } from "../../src/util/logger"; -const silentLogger = (): Logger => { - return { error: () => {}, info: () => {}, success: () => {}, warn: () => {} }; +const recordingLogger = (): { logger: Logger; successes: string[]; warns: string[] } => { + const successes: string[] = []; + const warns: string[] = []; + + return { + logger: { error: () => {}, info: () => {}, success: (message) => successes.push(message), warn: (message) => warns.push(message) }, + successes, + warns, + }; }; describe("parseDeployedUrl", () => { @@ -32,6 +39,12 @@ describe("parseDeployedUrl", () => { expect(parseDeployedUrl("Total Upload: 1 KiB\nNo URL here")).toBeUndefined(); }); + + it("returns undefined when stdout was not captured at all", () => { + expect.assertions(1); + + expect(parseDeployedUrl(undefined)).toBeUndefined(); + }); }); describe("autoLinkFromDeployOutput", () => { @@ -46,15 +59,15 @@ describe("autoLinkFromDeployOutput", () => { rmSync(workdir, { force: true, recursive: true }); }); - it("writes the link from captured deploy output", () => { + it("writes the link when the checkout has none", () => { expect.assertions(2); autoLinkFromDeployOutput({ cwd: workdir, env: "production", - logger: silentLogger(), + logger: recordingLogger().logger, now: () => "2026-01-01T00:00:00.000Z", - output: " https://my-worker.acme.workers.dev\n", + url: "https://my-worker.acme.workers.dev", }); const written = JSON.parse(readFileSync(join(workdir, LINKED_PROJECT_FILE), "utf8")); @@ -63,23 +76,64 @@ describe("autoLinkFromDeployOutput", () => { expect(written.workerName).toBe("my-worker"); }); - it("does nothing when output was not captured", () => { + it("does nothing when the deploy output carried no URL", () => { expect.assertions(1); - autoLinkFromDeployOutput({ cwd: workdir, logger: silentLogger(), output: undefined }); + autoLinkFromDeployOutput({ cwd: workdir, logger: recordingLogger().logger, url: undefined }); expect(existsSync(join(workdir, LINKED_PROJECT_FILE))).toBe(false); }); - it("never overwrites an existing link", () => { - expect.assertions(1); + it("is a silent no-op when the recorded link already matches", () => { + expect.assertions(3); + + writeLinkedProject(workdir, { env: "production", linkedAt: "2020-01-01T00:00:00.000Z", workerUrl: "https://auto.acme.workers.dev" }); + + const recorded = recordingLogger(); + + autoLinkFromDeployOutput({ cwd: workdir, env: "production", logger: recorded.logger, url: "https://auto.acme.workers.dev" }); + + const written = JSON.parse(readFileSync(join(workdir, LINKED_PROJECT_FILE), "utf8")); + + // Untouched — same stamp, no re-write. + expect(written.linkedAt).toBe("2020-01-01T00:00:00.000Z"); + expect(recorded.warns).toEqual([]); + expect(recorded.successes).toEqual([]); + }); + it("warns and keeps the recorded value when the deployed URL differs", () => { + expect.assertions(4); + + // The stale-link failure this exists to surface: `run` / `logs` / + // `--migrate` would otherwise keep targeting a URL this deploy no longer + // publishes to — but rewriting an explicit `lunora link` is equally wrong. writeLinkedProject(workdir, { workerUrl: "https://manual.workers.dev" }); - autoLinkFromDeployOutput({ cwd: workdir, logger: silentLogger(), output: "https://auto.acme.workers.dev" }); + const recorded = recordingLogger(); + + autoLinkFromDeployOutput({ cwd: workdir, logger: recorded.logger, url: "https://auto.acme.workers.dev" }); const written = JSON.parse(readFileSync(join(workdir, LINKED_PROJECT_FILE), "utf8")); expect(written.workerUrl).toBe("https://manual.workers.dev"); + expect(recorded.warns).toHaveLength(1); + // Names both URLs and the one command that resolves the disagreement. + expect(recorded.warns[0]).toContain("https://manual.workers.dev"); + expect(recorded.warns[0]).toContain("lunora link --url https://auto.acme.workers.dev"); + }); + + it("treats a link recorded for another --env as a mismatch, not a target to clobber", () => { + expect.assertions(2); + + writeLinkedProject(workdir, { env: "production", workerUrl: "https://prod.acme.workers.dev" }); + + const recorded = recordingLogger(); + + autoLinkFromDeployOutput({ cwd: workdir, env: "staging", logger: recorded.logger, url: "https://staging.acme.workers.dev" }); + + const written = JSON.parse(readFileSync(join(workdir, LINKED_PROJECT_FILE), "utf8")); + + expect(written.workerUrl).toBe("https://prod.acme.workers.dev"); + expect(recorded.warns).toHaveLength(1); }); }); diff --git a/packages/cli/__tests__/util/health-probe.test.ts b/packages/cli/__tests__/util/health-probe.test.ts new file mode 100644 index 0000000000..925fa8e01e --- /dev/null +++ b/packages/cli/__tests__/util/health-probe.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { HealthFetch } from "../../src/util/health-probe"; +import { HEALTH_PATH, HEALTH_READY_PATH, joinHealthUrl, probeHealth } from "../../src/util/health-probe"; + +/** No real waiting between retries — the delay is injected in every test here. */ +const noSleep = async (): Promise => {}; + +describe("joinHealthUrl", () => { + it("does not double the slash on a base URL that ends in one", () => { + expect.assertions(2); + + expect(joinHealthUrl("https://app.workers.dev/")).toBe("https://app.workers.dev/_lunora/health"); + expect(joinHealthUrl("https://app.workers.dev", HEALTH_READY_PATH)).toBe("https://app.workers.dev/_lunora/health/ready"); + }); +}); + +describe("probeHealth", () => { + it("is green on a 2xx and asks only once by default", async () => { + expect.assertions(3); + + const fetchImpl = vi.fn(async () => { + return { ok: true, status: 200 }; + }); + + const result = await probeHealth({ baseUrl: "https://app.workers.dev", fetchImpl }); + + expect(result.error).toBeUndefined(); + expect(result.url).toBe("https://app.workers.dev/_lunora/health"); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it("retries up to the attempt budget and passes once the deploy propagates", async () => { + expect.assertions(2); + + // The reason the budget exists: a version that isn't serving yet answers + // 503 on the first probe and 200 moments later. + const responses = [ + { ok: false, status: 503 }, + { ok: false, status: 503 }, + { ok: true, status: 200 }, + ]; + const fetchImpl = vi.fn(async () => responses.shift() ?? { ok: true, status: 200 }); + + const result = await probeHealth({ attempts: 5, baseUrl: "https://app.workers.dev", fetchImpl, sleep: noSleep }); + + expect(result.error).toBeUndefined(); + expect(fetchImpl).toHaveBeenCalledTimes(3); + }); + + it("reports the last failure after exhausting the budget", async () => { + expect.assertions(2); + + const fetchImpl = vi.fn(async () => { + return { ok: false, status: 503 }; + }); + + const result = await probeHealth({ attempts: 3, baseUrl: "https://app.workers.dev", fetchImpl, sleep: noSleep }); + + expect(result.error).toContain("returned HTTP 503"); + expect(fetchImpl).toHaveBeenCalledTimes(3); + }); + + it("falls back to the next path when the first 404s (an older deployment has no readiness gate)", async () => { + expect.assertions(3); + + const fetchImpl = vi.fn(async (url: string) => (url.endsWith("/ready") ? { ok: false, status: 404 } : { ok: true, status: 200 })); + + const result = await probeHealth({ baseUrl: "https://app.workers.dev", fetchImpl, paths: [HEALTH_READY_PATH, HEALTH_PATH] }); + + expect(result.error).toBeUndefined(); + expect(result.url).toBe("https://app.workers.dev/_lunora/health"); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it("does not fall through on a non-404 — a 503 on the readiness gate is the answer", async () => { + expect.assertions(2); + + const fetchImpl = vi.fn(async () => { + return { ok: false, status: 503 }; + }); + + const result = await probeHealth({ baseUrl: "https://app.workers.dev", fetchImpl, paths: [HEALTH_READY_PATH, HEALTH_PATH] }); + + expect(result.error).toContain("/_lunora/health/ready returned HTTP 503"); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it("reports a transport failure as red without throwing", async () => { + expect.assertions(1); + + const fetchImpl = vi.fn(async () => { + throw new Error("getaddrinfo ENOTFOUND"); + }); + + const result = await probeHealth({ baseUrl: "https://app.workers.dev", fetchImpl, sleep: noSleep }); + + expect(result.error).toContain("could not reach https://app.workers.dev/_lunora/health (getaddrinfo ENOTFOUND)"); + }); + + it("gives the default fetch a per-attempt deadline so a silent worker cannot hang the probe", async () => { + expect.assertions(3); + + // The real hazard: Node's `fetch` never times out on its own, so a + // connection that is accepted and then goes quiet would block forever. + // Assert the abort signal reaches `fetch` rather than the wall-clock + // behaviour, which would mean actually waiting for a timeout. + const globalFetch = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("The operation was aborted due to timeout")); + + try { + const result = await probeHealth({ baseUrl: "https://app.workers.dev", sleep: noSleep, timeoutMs: 25 }); + + expect(result.error).toContain("could not reach"); + + const [, init] = globalFetch.mock.calls[0] as [string, RequestInit | undefined]; + + expect(init?.signal).toBeInstanceOf(AbortSignal); + expect(init?.signal?.aborted).toBe(false); + } finally { + globalFetch.mockRestore(); + } + }); +}); diff --git a/packages/cli/docs/index.mdx b/packages/cli/docs/index.mdx index 7e14ebba00..730b08fb29 100644 --- a/packages/cli/docs/index.mdx +++ b/packages/cli/docs/index.mdx @@ -18,7 +18,7 @@ lunora add # add a feature to the current project lunora view [--remote] # open the Lunora studio in your browser lunora docs [section] # open the docs site in your browser lunora info [--json] # print versions, wrangler summary, schema overview -lunora doctor # preflight the project (bindings, placeholders, secrets) +lunora doctor [--format ] # preflight the project (bindings, placeholders, secrets) lunora registry # component registry lunora rules # install the AI agent skills into .agents/skills/ @@ -48,6 +48,7 @@ lunora deploy [--env ] # codegen, validate wrangler, then wrang [--migrate] [--prebuilt] # --preview uploads a version (no live traffic) [--preview] [--dry-run] # --dry-run validates + bundles, never publishes [--temporary] # --temporary deploys with no account (~60min, then claim) + [--health-check] # --health-check probes /_lunora/health/ready after deploying lunora link --url [--env ] # link this checkout to its deployed worker lunora deployments # history + traffic control [--env ] [--yes] @@ -179,14 +180,69 @@ A read-only preflight over the current project. It reports pass / warn / fail fo by the worker entry. - **Version skew** — `@lunora/*` packages spanning different versions, or mixing release channels (e.g. stable + alpha). +- **CLI shadowing** — a globally-installed `lunora` running instead of the + project's own, so the report describes a project this CLI is not pinned to. ```bash lunora doctor +lunora doctor --format json # machine-readable; one JSON document on stdout ``` It writes nothing and exits non-zero when any hard check fails, so it works as a CI gate. See [Debugging](/docs/concepts/debugging) for how to act on each finding. +#### Machine-readable output + +`--format json` prints exactly one JSON document on stdout; the human report is +still rendered, but on stderr, so the document stays pipeable. The exit code is +identical in both formats. + +```jsonc +{ + "code": 1, + "findings": [ + { + "code": "d1-placeholder-id", + "fix": "Run `wrangler d1 create ` …", + "level": "fail", + "message": "D1 binding \"DB\" has a placeholder database_id …", + }, + // …the info- and pass-level findings the summary counts + ], + "ok": false, + "summary": { "fail": 1, "info": 1, "pass": 1, "warn": 0 }, +} +``` + +`ok` is redundant with `code` on purpose — it is the field a consumer without a +shell reaches for first. `pass`-level findings are included, so the document +describes everything that was checked, not only what went wrong. + +#### Finding codes + +Branch on `finding.code`, never on `message` — the codes are the contract, the +prose is not. New codes are added over time; treat an unknown one as advisory. + +| Code | Level | Meaning | +| -------------------------------- | ----- | ---------------------------------------------------------------------------- | +| `admin-token-missing` | info | `LUNORA_ADMIN_TOKEN` is not set (studio / admin RPCs stay disabled). | +| `admin-token-set` | pass | `LUNORA_ADMIN_TOKEN` is set. | +| `cli-shadowed` | warn | The running `lunora` is not the project's own install. | +| `d1-placeholder-id` | fail | A D1 binding still carries a scaffold `database_id`. | +| `declared-export-missing` | fail | A declared container / workflow / agent is not exported by the worker entry. | +| `declared-export-ok` | pass | A declared container / workflow / agent is exported by the worker entry. | +| `dev-vars-missing-secret` | warn | `.dev.vars` has secret-looking keys left at placeholder values. | +| `email-destination-placeholder` | warn | A `send_email` binding has a placeholder `destination_address`. | +| `vector-metadata-index-required` | info | A declared Vectorize metadata filter needs its metadata index created. | +| `vector-metadata-unfilterable` | warn | A metadata property has a type Vectorize cannot filter on. | +| `version-counter-spread` | info | Same-channel `@lunora/*` pre-release counters differ (normal, worth noting). | +| `version-skew-channels` | warn | `@lunora/*` packages mix release channels (stable + alpha). | +| `version-skew-cores` | warn | `@lunora/*` packages span different `major.minor.patch` versions. | +| `wrangler-missing` | fail | No `wrangler.jsonc` was found. | +| `wrangler-shard-binding-missing` | fail | `wrangler.jsonc` is missing the `SHARD` durable-object binding. | +| `wrangler-shard-binding-ok` | pass | `wrangler.jsonc` declares the `SHARD` durable-object binding. | +| `wrangler-unparseable` | fail | `wrangler.jsonc` was found but is not valid JSONC. | + ### `lunora dev` Starts three concurrent processes: `wrangler dev` (Worker), the embedded @@ -235,8 +291,9 @@ immediately after a successful deploy. lunora deploy lunora deploy --env staging lunora deploy --migrate --migrate-token $LUNORA_ADMIN_TOKEN -lunora deploy --temporary # no Cloudflare account needed -lunora deploy --dry-run # run every pre-deploy gate, publish nothing +lunora deploy --temporary # no Cloudflare account needed +lunora deploy --dry-run # run every pre-deploy gate, publish nothing +lunora deploy --health-check # …then prove the new version answers ``` You don't need a Cloudflare account to try a deploy. `--temporary` ships to a @@ -248,13 +305,82 @@ authenticated, so drop `--temporary` once you've signed in.) `--dry-run` runs the full pre-deploy pipeline — codegen, the schema-drift gate, `wrangler.jsonc` validation, and the wrangler bundle — without publishing. -A successful first deploy auto-writes `.lunora/project.json` (see `lunora link`) -from the deployed URL, so follow-up commands don't need `--url`. +A successful deploy auto-writes `.lunora/project.json` (see `lunora link`) from +the deployed URL, so follow-up commands don't need `--url`. Every real deploy +re-checks it: if the URL you just published to disagrees with the recorded one +(a custom domain added, the worker renamed), the deploy **warns and keeps the +recorded value** rather than rewriting an explicit `lunora link` — run +`lunora link --url ` to accept the change. `--temporary` never writes a +link, since that account is gone in an hour. `--preview` uploads a new Worker **version** (`wrangler versions upload`) and -prints a preview URL instead of going live — production traffic is untouched, and -the post-deploy steps (migrations, baseline re-bless, auto-link) are skipped. The -`--ci` pipelines use it to deploy a preview on every pull / merge request. +reports its preview URL instead of going live — production traffic is untouched, +and the post-deploy steps (migrations, baseline re-bless, link write) are +skipped. The `--ci` pipelines use it to deploy a preview on every pull / merge +request. + +#### `--health-check` + +After a live deploy, probe the new version's health route +(`/_lunora/health/ready`, falling back to `/_lunora/health` on deployments +without a readiness gate) and fail the command when it never answers. Five +attempts, two seconds apart — a fresh version takes a moment to propagate, and a +fixed ceiling is what a CI timeout can be set against. + +It is opt-in on purpose: a worker whose health route is admin-gated or +unreachable from the runner must still be deployable, and a default-on network +step would turn a good deploy into a red build for an unrelated reason. + +A red probe exits non-zero and says which half failed — the deploy succeeded, +the probe did not. The probe runs **before** `--migrate`, so a worker that can't +serve is never migrated. + +#### Machine-readable output + +`--format json` writes exactly one JSON document to stdout (every human line, +including wrangler's own output, goes to stderr). It carries a `deployment` +object describing what this run put where: + +```jsonc +{ + "code": 0, + "deployment": { + "deployedAt": "2026-08-08T09:12:33.417Z", + "dryRun": false, + "env": "production", + "preview": false, + "url": "https://my-app.acme.workers.dev", + "workerName": "my-app", + }, + "healthCheck": { "ok": true, "url": "https://my-app.acme.workers.dev/_lunora/health/ready" }, + // …validation, schemaDrift, mintedSecretsFile +} +``` + +`dryRun` and `preview` are always present, so a consumer can tell "nothing went +live" from "went live" without inferring it from a missing `url`. There is no +version id: the pinned wrangler has no structured deploy output, and +`lunora deployments list` is the supported way to read one. + +A release pipeline reads the URL out of the same document it already checks the +exit code of: + +```bash +set -euo pipefail + +result=$(lunora deploy --env production --format json --health-check) +url=$(echo "$result" | jq -r '.deployment.url') + +# The document is printed on failure too, and a failed deploy carries no URL — +# without this guard `jq` yields "null" and the smoke test requests null/api/smoke, +# so the pipeline fails at the wrong step. +if [ -z "$url" ] || [ "$url" = "null" ]; then + echo "deploy reported no URL" >&2 + exit 1 +fi + +curl -fsS "$url/api/smoke" +``` ### `lunora build` diff --git a/packages/cli/src/commands/build/bundle-size.ts b/packages/cli/src/commands/build/bundle-size.ts new file mode 100644 index 0000000000..13a71a7bbb --- /dev/null +++ b/packages/cli/src/commands/build/bundle-size.ts @@ -0,0 +1,69 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { gzipSync } from "node:zlib"; + +/** Raw and compressed size of the bundle `lunora build` wrote to disk. */ +interface BundleSize { + /** How many files were counted — 0 never reaches a caller (see {@link measureBundle}). */ + files: number; + + /** + * Sum of the per-file gzip sizes. Cloudflare states its script-size limit + * against the compressed script, and gzip at zlib's default level is what + * wrangler itself reports ("Total Upload: … / gzip: …") — measuring the same + * way keeps the two numbers comparable instead of inviting a "which is + * right?" question. Summing per file rather than gzipping the concatenation + * is the conservative direction: separate streams share no dictionary. + */ + gzipBytes: number; + rawBytes: number; +} + +/** + * True for files that are part of the uploaded Worker. + * + * The out-dir also carries artifacts Cloudflare never sees: sourcemaps (which + * are larger than the script itself — counting them would roughly triple the + * number), the esbuild metafile `lunora deploy --outdir` writes alongside the + * bundle, and the README wrangler drops in to explain the directory. + */ +const isUploaded = (name: string): boolean => !name.endsWith(".map") && name !== "bundle-meta.json" && name !== "README.md"; + +/** + * Weigh the bundle in `outDir`, raw and gzipped. + * + * Returns `undefined` when the directory is missing or holds nothing uploadable + * — a caller must treat that as "not measured" and never as "zero bytes": a + * silent 0 is what a changed wrangler out-dir layout would look like, and it + * would read as the healthiest possible result. + */ +const measureBundle = (outDirectory: string): BundleSize | undefined => { + let entries; + + try { + entries = readdirSync(outDirectory, { recursive: true, withFileTypes: true }); + } catch { + return undefined; + } + + let files = 0; + let gzipBytes = 0; + let rawBytes = 0; + + for (const entry of entries) { + if (!entry.isFile() || !isUploaded(entry.name)) { + continue; + } + + const bytes = readFileSync(join(entry.parentPath, entry.name)); + + files += 1; + rawBytes += bytes.byteLength; + gzipBytes += gzipSync(bytes).byteLength; + } + + return files === 0 ? undefined : { files, gzipBytes, rawBytes }; +}; + +export type { BundleSize }; +export { measureBundle }; diff --git a/packages/cli/src/commands/build/handler.ts b/packages/cli/src/commands/build/handler.ts index 3a3565543b..87f3977774 100644 --- a/packages/cli/src/commands/build/handler.ts +++ b/packages/cli/src/commands/build/handler.ts @@ -9,9 +9,13 @@ import { parseApiSpec } from "../../util/api-spec"; import type { CommandHandler } from "../../util/command"; import { defineHandler } from "../../util/command"; import type { Logger } from "../../util/logger"; +import { isJsonFormat, loggerForFormat, printJson, validateOutputFormat } from "../../util/output-format"; import type { Spawner } from "../../util/spawn"; +import { defaultSpawner } from "../../util/spawn"; import type { DeployCommandResult } from "../deploy/handler"; import { runDeployCommand } from "../deploy/handler"; +import type { BundleSize } from "./bundle-size"; +import { measureBundle } from "./bundle-size"; import type { BuildOptions } from "./index"; /** Default artifact directory — gitignored alongside the other `.lunora/` state. */ @@ -43,6 +47,28 @@ interface BuildCommandOptions { target?: string; } +/** + * A build result is the deploy result plus the weight of what was produced. + * `bundle` is absent when the out-dir could not be measured (see + * {@link measureBundle}) and on a failed build, where nothing was written. + */ +interface BuildCommandResult extends DeployCommandResult { + bundle?: BundleSize; +} + +/** + * `defaultSpawner` with every child's stdout folded into stderr. + * + * `build` takes over its own `--format json` document (below), which means the + * `format` deploy sees is `undefined` — and `format` is what deploy would + * otherwise have used to keep wrangler's chatter off stdout. Forcing it here + * keeps stdout carrying exactly one JSON document. + */ +const stderrOnlySpawner: Spawner = async (descriptor) => defaultSpawner({ ...descriptor, stdoutToStderr: true }); + +/** Bundle sizes are always kilobytes-and-up; one unit keeps the two halves comparable at a glance. */ +const kib = (bytes: number): string => `${(bytes / 1024).toFixed(1)} KiB`; + /** * Write the binding manifest for the project at `projectRoot`. * @@ -93,37 +119,73 @@ const writeBindingManifest = (projectRoot: string, target: string, logger: Logge * schema-drift gate, binding provisioning, container preflight, wrangler * validation) and then emits the bundle to disk instead of publishing. */ -const runBuildCommand = async (options: BuildCommandOptions): Promise => { +const runBuildCommand = async (options: BuildCommandOptions): Promise => { const outDirectory = options.outDir ?? DEFAULT_OUT_DIR; + const jsonMode = isJsonFormat(options.format); + + // `build` owns its `--format json` document instead of delegating to + // deploy's: the bundle measurement below is what a CI consumer runs this + // command for, and the deploy result has no field to carry it. Everything + // human therefore goes to stderr from here on. + const logger = loggerForFormat(options.format, options.logger); + const emit = (result: BuildCommandResult): BuildCommandResult => { + if (jsonMode) { + printJson(result); + } + + return result; + }; + + const formatError = validateOutputFormat("build", options.format); + + if (formatError !== undefined) { + options.logger.error(formatError); + + return { code: 1, descriptor: undefined, error: formatError, validation: { problems: [], wranglerPath: undefined } }; + } const result = await runDeployCommand({ apiSpec: options.apiSpec, cwd: options.cwd, dryRun: true, - format: options.format, - logger: options.logger, + format: undefined, + interactive: jsonMode ? false : undefined, + logger, outDir: outDirectory, - spawner: options.spawner, + spawner: options.spawner ?? (jsonMode ? stderrOnlySpawner : undefined), target: options.target, }); if (result.code !== 0) { - return result; + return emit(result); } - options.logger.success(`build complete — bundle written to ${outDirectory}`); + logger.success(`build complete — bundle written to ${outDirectory}`); + + const bundle = measureBundle(resolve(options.cwd ?? process.cwd(), outDirectory)); + + if (bundle === undefined) { + // Never report 0 bytes for this: an out-dir layout we no longer + // recognise would measure as the healthiest possible bundle. + logger.warn(`could not weigh the bundle — nothing uploadable was found in ${outDirectory}`); + } else { + logger.info( + `bundle: ${kib(bundle.rawBytes)} raw, ${kib(bundle.gzipBytes)} gzipped across ${String(bundle.files)} file(s) — ` + + `Cloudflare's Worker size limit (3 MB Free, 10 MB Paid) applies to the gzipped number`, + ); + } if (options.emitBindings !== undefined) { - const { error } = writeBindingManifest(options.cwd ?? process.cwd(), options.emitBindings, options.logger); + const { error } = writeBindingManifest(options.cwd ?? process.cwd(), options.emitBindings, logger); if (error !== undefined) { - options.logger.error(error); + logger.error(error); - return { ...result, code: 1 }; + return emit({ ...result, bundle, code: 1 }); } } - return result; + return emit({ ...result, bundle }); }; /** `lunora build` handler (lazy-loaded via the command's `loader`). */ @@ -142,5 +204,5 @@ const execute: CommandHandler = defineHandler(async }); export { execute }; -export type { BuildCommandOptions }; +export type { BuildCommandOptions, BuildCommandResult }; export { runBuildCommand }; diff --git a/packages/cli/src/commands/deploy/handler.ts b/packages/cli/src/commands/deploy/handler.ts index 45853eea67..066e38357f 100644 --- a/packages/cli/src/commands/deploy/handler.ts +++ b/packages/cli/src/commands/deploy/handler.ts @@ -12,7 +12,6 @@ import { isMintableSecretKey, packageNamesFromBindings, parseDevVariableEntries, - readLinkedProject, requiredSecrets, resolveDeployDriver, upsertDevVariableLine, @@ -32,7 +31,7 @@ import { Project } from "ts-morph"; import { evaluateAdvisoryGate, resolveStrictAdvisories } from "../../util/advisory-gate"; import type { ApiSpec } from "../../util/api-spec"; import { parseApiSpec } from "../../util/api-spec"; -import { autoLinkFromDeployOutput } from "../../util/auto-link"; +import { autoLinkFromDeployOutput, parseDeployedUrl } from "../../util/auto-link"; import type { CommandHandler } from "../../util/command"; import { defineHandler } from "../../util/command"; import { renderDeploySummary } from "../../util/deploy-summary"; @@ -40,6 +39,8 @@ import { resolveTargetOrError } from "../../util/deploy-target"; import { detectPackageManager, execArgsFor } from "../../util/detect-package-manager"; import type { DockerProbe } from "../../util/docker"; import { isDockerAvailable } from "../../util/docker"; +import type { HealthFetch } from "../../util/health-probe"; +import { HEALTH_PATH, HEALTH_READY_PATH, probeHealth } from "../../util/health-probe"; import type { Logger } from "../../util/logger"; import { isJsonFormat, loggerForFormat, printJson, validateOutputFormat } from "../../util/output-format"; import reportPlatformDiagnostics from "../../util/platform-diagnostics"; @@ -52,6 +53,7 @@ import { defaultSpawner } from "../../util/spawn"; import { createTuiConfirm } from "../../util/tui-prompts"; import type { VectorMetadataIndex } from "../../util/vectorize-metadata"; import { ensureVectorMetadataIndexes, metadataTypeFor } from "../../util/vectorize-metadata"; +import readWranglerName from "../../util/wrangler-name"; import type { ListRemoteSecretsInputs, ListRemoteSecretsResult } from "../../util/wrangler-secrets"; import { listRemoteSecrets } from "../../util/wrangler-secrets"; import { validateWrangler } from "../../util/wrangler-validator"; @@ -83,6 +85,21 @@ interface DeployCommandOptions { fetchImpl?: FetchLike; /** Output format: `pretty` (default) or `json`. */ format?: string; + + /** + * After a successful live deploy, probe the new version's health route + * (`/_lunora/health/ready`, falling back to `/_lunora/health`) and fail the + * command when it never answers. Opt-in, not default-on: a worker whose + * health route is admin-gated or unreachable from CI must still be + * deployable, and a default network step would turn a successful deploy + * into a red build for an unrelated reason. + */ + healthCheck?: boolean; + + /** Injectable fetch for `--health-check`; defaults to the global `fetch`. */ + healthFetch?: HealthFetch; + /** Injectable inter-attempt delay for `--health-check`; injected in tests to skip the real wait. */ + healthSleep?: (ms: number) => Promise; /** Set to `false` to disable interactive spinners (test injection). */ interactive?: boolean; logger: Logger; @@ -176,12 +193,50 @@ interface DeployCommandOptions { updateSchemaBaseline?: boolean; } +/** + * What this run put where — the identity of the thing that was just deployed. + * + * Present on every run that reached (and completed) the wrangler invocation, + * including `--dry-run` and `--preview`, so a consumer can tell "nothing went + * live" from "went live" without inferring it from a missing `url`. A dry run + * publishes nothing and therefore never carries a `url`. + * + * No `versionId`: the pinned wrangler (4.114.0) has no structured deploy output + * and no flag that returns the version id — it only prints it in prose, and + * scraping a second value out of prose is exactly what this shouldn't do. The + * id is available from `lunora deployments list` after the fact. + */ +interface DeployedIdentity { + /** ISO-8601 stamp taken when the wrangler invocation returned. */ + deployedAt: string; + /** True when `--dry-run` validated + bundled without publishing. */ + dryRun: boolean; + /** The Cloudflare environment this run targeted, when `--env` named one. */ + env?: string; + /** True when `--preview` uploaded a version instead of shifting live traffic. */ + preview: boolean; + /** The URL wrangler reported publishing to; absent on a dry run, or when the output carried no URL. */ + url?: string; + /** The Worker name from the project's wrangler config. */ + workerName?: string; +} + interface DeployCommandResult { code: number; + /** What was deployed and where — set once the wrangler invocation completed. */ + deployment?: DeployedIdentity; descriptor: SpawnDescriptor | undefined; /** Set when the run aborted before reaching the wrangler invocation. */ error?: string; + /** + * The `--health-check` probe's verdict, when the flag was set and the probe + * ran. A red probe fails the command (`code` is non-zero) — but the deploy + * itself still succeeded, which is why the reason is reported separately + * from `error`. + */ + healthCheck?: { error?: string; ok: boolean; url: string }; + /** * The `.dev.vars`-shaped filename (never a full path, never a value) a * secret minted during this run was recorded into, when the missing- @@ -840,14 +895,18 @@ const validateMigrateDeployPreflight = (options: DeployCommandOptions): string | return undefined; } - // `wrangler deploy`'s published URL is never captured here, so without an - // explicit `--migrate-url` the downstream migration would default to + // The deployed URL is only known AFTER wrangler runs, and this gate runs + // before it — so there is nothing to default to here, and without an + // explicit `--migrate-url` the downstream migration would fall back to // `http://localhost:8787` (the dev worker) and apply against LOCAL state — // and ship the production admin bearer to whatever listens on that port. // Refuse before deploying rather than silently targeting localhost later. + // (A linked checkout satisfies this without the flag: the caller resolves + // `migrateUrl` through `resolveWorkerUrl`, which reads the link this + // deploy's predecessor recorded.) if (options.migrateUrl === undefined) { const message = - "--migrate requires --migrate-url — the deploy target URL is not captured automatically, refusing to default to localhost"; + "--migrate requires --migrate-url — the deploy target URL is only known after wrangler runs, and this gate runs before it; refusing to default to localhost"; options.logger.error(message); @@ -1065,6 +1124,62 @@ const provisionVectorMetadataIndexes = async (options: DeployCommandOptions, cwd } }; +/** Attempt budget + spacing for `--health-check`: a fresh version takes seconds to propagate, and a predictable ceiling is what a CI timeout is set against. */ +const HEALTH_CHECK_ATTEMPTS = 5; +const HEALTH_CHECK_DELAY_MS = 2000; + +/** + * The opt-in `--health-check` step: prove the version just deployed actually + * answers. Probes the readiness gate first and falls back to the aggregate route + * (older deployments have no `/ready`), retrying on a bounded budget because a + * single immediate probe of a still-propagating deploy is a coin flip. + * + * Returns `undefined` when the flag wasn't set. The URL comes from the deploy + * that just ran, falling back to the recorded link for THIS environment; with + * neither, the step refuses rather than guessing an origin. + */ +const runHealthCheckStep = async (options: DeployCommandOptions, cwd: string, deployedUrl: string | undefined): Promise => { + if (options.healthCheck !== true) { + return undefined; + } + + const baseUrl = deployedUrl ?? resolveWorkerUrl({ cwd, env: options.env }); + + if (baseUrl === undefined) { + const message = + "--health-check: the deploy succeeded, but no URL to probe could be resolved — wrangler's output carried none and this checkout has no link for this environment. Run `lunora link --url ` and re-deploy, or drop --health-check."; + + options.logger.error(message); + + return { error: message, ok: false, url: "" }; + } + + const probe = await probeHealth({ + attempts: HEALTH_CHECK_ATTEMPTS, + baseUrl, + delayMs: HEALTH_CHECK_DELAY_MS, + fetchImpl: options.healthFetch, + // The readiness gate answers "can this version serve"; the aggregate is + // the one that exists on older deployments. + paths: [HEALTH_READY_PATH, HEALTH_PATH], + sleep: options.healthSleep, + }); + + if (probe.error === undefined) { + options.logger.success(`health check ok (${probe.url})`); + + return { ok: true, url: probe.url }; + } + + // The deploy SUCCEEDED and the probe did not — different facts, and the + // message has to say which one failed or it reads as a broken deploy. + options.logger.error( + `--health-check: the deploy succeeded, but the new version did not answer after ${String(HEALTH_CHECK_ATTEMPTS)} attempt(s) — ${probe.error}`, + ); + + return { error: probe.error, ok: false, url: probe.url }; +}; + /** * After a successful `wrangler deploy`, run any requested data migrations and — * only when the whole operation succeeded — advance the committed schema @@ -1212,6 +1327,111 @@ const reportWranglerProblems = (validation: { problems: ReadonlyArray }, return true; }; +/** + * Assemble the wrangler {@link SpawnDescriptor}, including how its stdout is + * handled — the one decision that has to be right for `--format json` to stay + * pipeable: + * + * Pretty + publishing uses `captureStdout` (buffered AND teed, so the URL can be + * read while the user still watches live progress). Json + publishing uses + * `captureStdoutSilently` (buffered, never teed — the caller replays it to + * stderr), because `captureStdout` there would interleave with the single JSON + * document on stdout and corrupt it. A dry run has nothing to read, so its + * stdout is left alone (mapped to stderr in json mode). + */ +const buildDeploySpawn = (cwd: string, options: DeployCommandOptions, target: string): SpawnDescriptor => { + const jsonFormat = isJsonFormat(options.format); + // Read the deployed URL off wrangler's stdout on EVERY publishing run — a + // preview and a `--format json` deploy need to report where the thing went + // just as much as a first pretty deploy does, and a re-deploy is how a + // CHANGED url gets noticed. + const publishes = options.dryRun !== true; + + const deployCommand = buildDeployCommand(cwd, options, target); + const exec = execArgsFor(detectPackageManager(cwd), deployCommand.tool, deployCommand.args); + + return { + args: exec.args, + captureStdout: publishes && !jsonFormat, + captureStdoutSilently: publishes && jsonFormat, + command: exec.command, + cwd, + stdoutToStderr: jsonFormat && !publishes, + }; +}; + +interface CompleteDeployInputs { + cwd: string; + descriptor: SpawnDescriptor; + mintedSecretsFile: string | undefined; + options: DeployCommandOptions; + reblessSchemaBaseline: (() => void) | undefined; + /** Wrangler's captured stdout, or `undefined` when this run didn't capture it. */ + stdout: string | undefined; + validation: DeployCommandResult["validation"]; +} + +/** + * Everything that happens once `wrangler` has exited 0: name what was deployed, + * record the link, prove the new version answers, then finalize (migrations + + * baseline re-bless). Extracted from {@link executeDeploy} to keep both + * functions' cognitive complexity within the 15-node budget. + */ +const completeDeploy = async ({ + cwd, + descriptor, + mintedSecretsFile, + options, + reblessSchemaBaseline, + stdout, + validation, +}: CompleteDeployInputs): Promise => { + // A dry run publishes nothing, so it reports no URL — the discriminators say + // so explicitly rather than leaving a consumer to infer it from the absence. + const deployment: DeployedIdentity = { + deployedAt: new Date().toISOString(), + dryRun: options.dryRun === true, + env: options.env, + preview: options.preview === true, + url: options.dryRun === true ? undefined : parseDeployedUrl(stdout), + workerName: readWranglerName(cwd), + }; + + // A dry run published nothing, and a preview uploaded a Version without + // going live — either way, skip the post-deploy finalize (migrations / + // baseline re-bless), the link write, and the health probe, which only apply + // to a live deploy. The URL is still reported. + if (options.dryRun === true || options.preview === true) { + if (options.healthCheck === true) { + options.logger.warn( + `--health-check skipped: ${options.dryRun === true ? "a dry run publishes nothing" : "a preview version serves no live traffic"}`, + ); + } + + return { code: 0, deployment, descriptor, mintedSecretsFile, validation }; + } + + // Zero-effort linking: record the deployed URL, warn instead of clobbering + // when an existing link disagrees. Skipped for `--temporary`: that account + // is deleted in ~60 minutes, so its URL must never become the checkout's + // recorded target. + if (options.temporary !== true) { + autoLinkFromDeployOutput({ cwd, env: options.env, logger: options.logger, url: deployment.url }); + } + + // Prove the new version answers BEFORE running migrations against it — a + // worker that can't serve is not one to migrate. + const healthCheck = await runHealthCheckStep(options, cwd, deployment.url); + + if (healthCheck?.error !== undefined) { + return { code: 1, deployment, descriptor, healthCheck, mintedSecretsFile, validation }; + } + + const finalized = await finalizeSuccessfulDeploy(options, cwd, descriptor, validation, reblessSchemaBaseline, mintedSecretsFile); + + return { ...finalized, deployment, healthCheck }; +}; + const executeDeploy = async (options: DeployCommandOptions): Promise => { const cwd = options.cwd ?? process.cwd(); const interactive = isInteractive(options); @@ -1334,45 +1554,25 @@ const executeDeploy = async (options: DeployCommandOptions): Promise = defineHandler(asyn dryRun: options.dryRun === true, env: options.env, format: options.format, + healthCheck: options.healthCheck === true, logger, migrate: options.migrate === true, migrateToken: options.migrateToken, @@ -1449,5 +1655,5 @@ const execute: CommandHandler = defineHandler(asyn }); export { execute }; -export type { DeployCommandOptions, DeployCommandResult }; +export type { DeployCommandOptions, DeployCommandResult, DeployedIdentity }; export { runDeployCommand }; diff --git a/packages/cli/src/commands/deploy/index.ts b/packages/cli/src/commands/deploy/index.ts index 036e21388e..a889ff0733 100644 --- a/packages/cli/src/commands/deploy/index.ts +++ b/packages/cli/src/commands/deploy/index.ts @@ -23,6 +23,12 @@ const deployCommand: Command = { { description: "Validate, bundle, and run pre-deploy gates without publishing (wrangler deploy --dry-run)", name: "dry-run", type: Boolean }, { description: "Cloudflare environment name", name: "env", type: String }, { description: "Output format: pretty (default) or json", name: "format", type: String }, + { + description: + "After the deploy, probe the new version's health route (/_lunora/health/ready, falling back to /_lunora/health) and fail if it never answers", + name: "health-check", + type: Boolean, + }, { description: "After a successful deploy, run pending data migrations against the live worker", name: "migrate", type: Boolean }, { description: @@ -85,6 +91,7 @@ export type DeployOptions = CreateOptions<{ "dry-run": boolean | undefined; env: string | undefined; format: string | undefined; + "health-check": boolean | undefined; migrate: boolean | undefined; "migrate-token": string | undefined; "migrate-url": string | undefined; diff --git a/packages/cli/src/commands/doctor/handler.ts b/packages/cli/src/commands/doctor/handler.ts index 13efd0db73..7e6e5ac478 100644 --- a/packages/cli/src/commands/doctor/handler.ts +++ b/packages/cli/src/commands/doctor/handler.ts @@ -1,5 +1,5 @@ -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { join, sep } from "node:path"; import { DEV_VARS_FILE, discoverSchemaInfo, inferLunoraBindings, isPlaceholderValue, parseDevVariableEntries } from "@lunora/config"; import type { WranglerConfig } from "@lunora/config/cloudflare"; @@ -8,13 +8,48 @@ import { collectExportGaps, findWranglerFile, readWranglerJsonc, validateWrangle import type { CommandHandler } from "../../util/command"; import { defineHandler } from "../../util/command"; import type { Logger } from "../../util/logger"; +import { isJsonFormat, loggerForFormat, printJson, validateOutputFormat } from "../../util/output-format"; import { createMetadataIndexArgs, metadataTypeFor } from "../../util/vectorize-metadata"; import type { DoctorOptions } from "./index"; /** Severity of a single doctor check. `fail` drives a non-zero exit; `warn`/`info`/`pass` don't. */ type FindingLevel = "fail" | "info" | "pass" | "warn"; +/** + * Every diagnostic code `lunora doctor` can emit, sorted. + * + * The codes — not the English messages — are the contract behind `--format + * json`: an agent or CI job branches on `finding.code`, so a copy-edit to a + * message must never rename a diagnostic. Adding, renaming or removing an entry + * here is a public-API change; the docs table in `packages/cli/docs/index.mdx` + * is asserted against this list, so the two cannot drift. + */ +const DOCTOR_CODES = [ + "admin-token-missing", + "admin-token-set", + "cli-shadowed", + "d1-placeholder-id", + "declared-export-missing", + "declared-export-ok", + "dev-vars-missing-secret", + "email-destination-placeholder", + "vector-metadata-index-required", + "vector-metadata-unfilterable", + "version-counter-spread", + "version-skew-channels", + "version-skew-cores", + "wrangler-missing", + "wrangler-shard-binding-missing", + "wrangler-shard-binding-ok", + "wrangler-unparseable", +] as const; + +/** A stable identifier for one doctor diagnostic. See {@link DOCTOR_CODES}. */ +type DoctorCode = (typeof DOCTOR_CODES)[number]; + interface Finding { + /** Stable machine-readable identifier for this diagnostic. */ + code: DoctorCode; /** Optional remediation hint printed under a non-pass finding. */ fix?: string; level: FindingLevel; @@ -26,10 +61,21 @@ interface DoctorResult { /** Process exit code: 1 when any finding is `fail`, else 0. */ code: number; findings: ReadonlyArray; + /** `true` when nothing failed — redundant with `code`, and the field a shell-free consumer reaches for first. */ + ok: boolean; + /** Finding count per level. */ + summary: Record; } interface RunDoctorOptions { cwd?: string; + + /** + * Path of the running `lunora` executable (defaults to `process.argv[1]`). + * Overridable so the CLI-shadow check is testable without re-launching the + * process, the same seam `cwd` provides for the filesystem checks. + */ + executablePath?: string; logger: Logger; } @@ -70,6 +116,7 @@ const readWrangler = (cwd: string): { parsed: WranglerConfig | undefined; path: const checkWrangler = (parsed: WranglerConfig | undefined, path: string | undefined, findings: Finding[]): void => { if (path === undefined) { findings.push({ + code: "wrangler-missing", fix: "Run `lunora init` (or `lunora dev`) to scaffold and reconcile wrangler.jsonc.", level: "fail", message: "wrangler.jsonc not found.", @@ -79,7 +126,7 @@ const checkWrangler = (parsed: WranglerConfig | undefined, path: string | undefi } if (parsed === undefined) { - findings.push({ fix: `Check ${path} is valid JSONC.`, level: "fail", message: `Could not parse ${path}.` }); + findings.push({ code: "wrangler-unparseable", fix: `Check ${path} is valid JSONC.`, level: "fail", message: `Could not parse ${path}.` }); return; } @@ -88,9 +135,14 @@ const checkWrangler = (parsed: WranglerConfig | undefined, path: string | undefi const shardError = report.errors.find((error) => error.includes("SHARD")); if (shardError === undefined) { - findings.push({ level: "pass", message: "wrangler.jsonc present with a SHARD durable-object binding." }); + findings.push({ code: "wrangler-shard-binding-ok", level: "pass", message: "wrangler.jsonc present with a SHARD durable-object binding." }); } else { - findings.push({ fix: "Run `lunora dev` to auto-reconcile, or add the binding manually.", level: "fail", message: shardError }); + findings.push({ + code: "wrangler-shard-binding-missing", + fix: "Run `lunora dev` to auto-reconcile, or add the binding manually.", + level: "fail", + message: shardError, + }); } }; @@ -111,6 +163,7 @@ const checkVectorMetadataIndexes = (cwd: string, findings: Finding[]): void => { if (type === undefined) { findings.push({ + code: "vector-metadata-unfilterable", fix: "Filter on a string, number or boolean column, or drop it from `metadata`.", level: "warn", message: `vector index "${declaration.index}" declares metadata "${declaration.property}", whose type Vectorize cannot filter on.`, @@ -120,6 +173,7 @@ const checkVectorMetadataIndexes = (cwd: string, findings: Finding[]): void => { } findings.push({ + code: "vector-metadata-index-required", fix: `wrangler ${createMetadataIndexArgs({ index: declaration.index, property: declaration.property, type }).join(" ")}`, level: "info", message: `vector index "${declaration.index}" filters on metadata "${declaration.property}" — that needs a Vectorize metadata index (\`lunora deploy\` creates it).`, @@ -142,6 +196,7 @@ const checkD1Placeholders = (parsed: WranglerConfig | undefined, findings: Findi const label = typeof database.binding === "string" && database.binding.length > 0 ? database.binding : ""; findings.push({ + code: "d1-placeholder-id", fix: "Run `wrangler d1 create ` and paste the returned database_id into wrangler.jsonc.", level: "fail", message: `D1 binding "${label}" has a placeholder database_id ("${databaseId || ""}").`, @@ -165,6 +220,7 @@ const checkEmailDestination = (parsed: WranglerConfig | undefined, findings: Fin const label = typeof binding.name === "string" && binding.name.length > 0 ? binding.name : "send_email"; findings.push({ + code: "email-destination-placeholder", fix: "Set destination_address to a verified Cloudflare Email Routing address.", level: "warn", message: `send_email binding "${label}" has a placeholder destination_address ("${destination}").`, @@ -195,6 +251,7 @@ const checkDevVariables = (cwd: string, findings: Finding[]): void => { if (unfilled.length > 0) { findings.push({ + code: "dev-vars-missing-secret", fix: "Run `lunora dev` to auto-generate secrets, or fill them in by hand.", level: "warn", message: `${DEV_VARS_FILE} has unfilled secret value(s): ${unfilled.join(", ")}.`, @@ -208,12 +265,13 @@ const checkAdminToken = (findings: Finding[]): void => { if (token === undefined || token.trim() === "") { findings.push({ + code: "admin-token-missing", fix: "Set LUNORA_ADMIN_TOKEN (env or `.dev.vars`) to enable admin RPCs / studio.", level: "info", message: "LUNORA_ADMIN_TOKEN is not set.", }); } else { - findings.push({ level: "pass", message: "LUNORA_ADMIN_TOKEN is set." }); + findings.push({ code: "admin-token-set", level: "pass", message: "LUNORA_ADMIN_TOKEN is set." }); } }; @@ -253,11 +311,12 @@ const checkDeclaredExports = async (cwd: string, findings: Finding[]): Promise candidate.exported)) { - findings.push({ level: "pass", message: `${entry.kind} "${entry.exportName}" is exported by the worker entry.` }); + findings.push({ code: "declared-export-ok", level: "pass", message: `${entry.kind} "${entry.exportName}" is exported by the worker entry.` }); } for (const gap of gaps) { findings.push({ + code: "declared-export-missing", fix: `Add \`export * from "./lunora/_generated/${gap.module}"\` to your worker entry (or re-run \`vis generate lunora-${gap.kind}\`).`, level: "fail", message: `${gap.kind} "${gap.exportName}" is declared but ${gap.className} is not exported by the worker entry.`, @@ -355,6 +414,7 @@ const checkVersionSkew = (cwd: string, findings: Finding[]): void => { if (cores.size > 1) { findings.push({ + code: "version-skew-cores", fix: 'Run `pnpm update "@lunora/*" lunorash` (or `npm`/`yarn` equivalent) so every Lunora package moves to the same release.', level: "warn", message: `Lunora packages span ${String(cores.size)} different versions: ${describe(parsed)}.`, @@ -365,6 +425,7 @@ const checkVersionSkew = (cwd: string, findings: Finding[]): void => { if (channels.size > 1) { findings.push({ + code: "version-skew-channels", fix: "Pick one channel (all stable, or all alpha/beta) and update the odd package out.", level: "warn", message: `Lunora packages mix release channels (${[...channels].map((channel) => (channel === "" ? "stable" : channel)).join(" + ")}): ${describe(parsed)}.`, @@ -383,6 +444,7 @@ const checkVersionSkew = (cwd: string, findings: Finding[]): void => { // defect — so this is INFO with the spread, not a warning. It's still the // number to check first when behavior disagrees with the docs. findings.push({ + code: "version-counter-spread", level: "info", message: counters.length === parsed.length && lowest === highest @@ -392,6 +454,70 @@ const checkVersionSkew = (cwd: string, findings: Finding[]): void => { } }; +/** The two packages that can install a project-local `lunora` binary. */ +const LOCAL_CLI_PACKAGES = [join("@lunora", "cli"), "lunorash"]; + +/** + * A `lunora` from somewhere other than the project's own install → WARN. + * + * A globally-installed binary shadowing the project's pinned one produces a + * report about a project the running CLI may be the wrong version for, and no + * other check can see it: `checkVersionSkew` reads the manifest, which is + * exactly the file the shadowing binary is ignoring. + * + * The comparison is *containment* — is the running module inside the project's + * installed CLI package? — rather than a path equality against + * `node_modules/.bin/lunora`. pnpm writes that bin as a shell shim, not a + * symlink, so its `realpath` is the shim itself and never equals the running + * `dist/bin.mjs`; equality would warn on every pnpm project. Containment holds + * for pnpm's symlinked package dirs, npm/yarn's hoisted ones, and a launch + * through the bin shim alike. + */ +const checkCliShadow = (cwd: string, executablePath: string | undefined, findings: Finding[]): void => { + if (executablePath === undefined) { + return; + } + + const roots: string[] = []; + + for (const packageName of LOCAL_CLI_PACKAGES) { + const packagePath = join(cwd, "node_modules", packageName); + + if (!existsSync(packagePath)) { + continue; + } + + try { + roots.push(realpathSync(packagePath)); + } catch { + // A dangling link is an install problem, not a shadowing one. + } + } + + if (roots.length === 0) { + return; // No project-local install: a global-only project, not a defect. + } + + let running: string; + + try { + running = realpathSync(executablePath); + } catch { + return; + } + + if (roots.some((root) => running === root || running.startsWith(`${root}${sep}`))) { + return; + } + + findings.push({ + code: "cli-shadowed", + fix: "Run the project's own CLI: `pnpm exec lunora …` (or `npx lunora …`).", + level: "warn", + message: `the running lunora (${running}) is not the project's own install (${roots.join(", ")}).`, + }); +}; + /** * Pure, testable preflight core: run the read-only project checks against `cwd` * and return the aggregated findings + the exit code (1 if any hard FAIL). Does @@ -411,11 +537,18 @@ const runDoctor = async (options: RunDoctorOptions): Promise => { checkAdminToken(findings); checkVersionSkew(cwd, findings); checkVectorMetadataIndexes(cwd, findings); + checkCliShadow(cwd, options.executablePath ?? process.argv[1], findings); await checkDeclaredExports(cwd, findings); - const code = findings.some((finding) => finding.level === "fail") ? 1 : 0; + const summary: Record = { fail: 0, info: 0, pass: 0, warn: 0 }; + + for (const finding of findings) { + summary[finding.level] += 1; + } - return { code, findings }; + const code = summary.fail > 0 ? 1 : 0; + + return { code, findings, ok: code === 0, summary }; }; const LEVEL_LABEL: Record = { fail: "FAIL", info: "INFO", pass: "PASS", warn: "WARN" }; @@ -440,8 +573,7 @@ const renderReport = (result: DoctorResult, logger: Logger): void => { } } - const fails = result.findings.filter((finding) => finding.level === "fail").length; - const warns = result.findings.filter((finding) => finding.level === "warn").length; + const { fail: fails, warn: warns } = result.summary; if (fails > 0) { logger.error(`${String(fails)} failure(s), ${String(warns)} warning(s).`); @@ -452,14 +584,45 @@ const renderReport = (result: DoctorResult, logger: Logger): void => { } }; -/** `lunora doctor` handler (lazy-loaded via the command's `loader`). */ -const execute: CommandHandler = defineHandler(async ({ cwd, logger }) => { - const result = await runDoctor({ cwd, logger }); +interface DoctorCommandOptions extends RunDoctorOptions { + /** Output format: `pretty` (default) or `json`. */ + format?: string; +} + +/** + * Run the preflight and emit it in the requested format. `pretty` prints the + * human report exactly as before; `json` routes that same report to stderr (via + * {@link loggerForFormat}) and puts a single {@link DoctorResult} document on + * stdout, so `lunora doctor --format json | …` stays pipeable. The exit code is + * the same in both formats. + */ +const runDoctorCommand = async (options: DoctorCommandOptions): Promise => { + const formatError = validateOutputFormat("doctor", options.format); + + if (formatError !== undefined) { + options.logger.error(formatError); + + return { code: 1, findings: [], ok: false, summary: { fail: 0, info: 0, pass: 0, warn: 0 } }; + } + + const logger = loggerForFormat(options.format, options.logger); + const result = await runDoctor({ ...options, logger }); renderReport(result, logger); + if (isJsonFormat(options.format)) { + printJson(result); + } + + return result; +}; + +/** `lunora doctor` handler (lazy-loaded via the command's `loader`). */ +const execute: CommandHandler = defineHandler(async ({ cwd, logger, options }) => { + const result = await runDoctorCommand({ cwd, format: options.format, logger }); + return { code: result.code }; }); -export { execute, runDoctor }; -export type { DoctorResult, Finding, FindingLevel, RunDoctorOptions }; +export { DOCTOR_CODES, execute, runDoctor, runDoctorCommand }; +export type { DoctorCode, DoctorCommandOptions, DoctorResult, Finding, FindingLevel, RunDoctorOptions }; diff --git a/packages/cli/src/commands/doctor/index.ts b/packages/cli/src/commands/doctor/index.ts index 8dfe6a4228..9b74a83c38 100644 --- a/packages/cli/src/commands/doctor/index.ts +++ b/packages/cli/src/commands/doctor/index.ts @@ -5,19 +5,26 @@ import type { Command, CommandExecute, CreateOptions, Toolbox } from "@visulima/ * wrangler config (SHARD DO binding, placeholder D1 ids), the `send_email` * destination, `.dev.vars` secrets, and `LUNORA_ADMIN_TOKEN`, then prints a * pass/warn/fail report. Exits 1 when any hard check FAILs so it's CI-friendly. + * + * `--format json` emits the same findings as one JSON document on stdout, each + * carrying a stable `code`, so an agent or CI job can branch on the diagnostic + * instead of scraping the prose. */ const doctorCommand: Command = { description: "Preflight the current Lunora project (wrangler bindings, placeholders, dev secrets)", - examples: [["lunora doctor", "Run the project preflight checks"]], + examples: [ + ["lunora doctor", "Run the project preflight checks"], + ["lunora doctor --format json", "Emit the findings as a machine-readable JSON document"], + ], group: "Project", loader: () => import("./handler").then((m) => { return { default: m.execute as CommandExecute }; }), name: "doctor", - options: [], + options: [{ description: "Output format: pretty (default) or json", name: "format", type: String }], }; export { doctorCommand }; -export type DoctorOptions = CreateOptions>; +export type DoctorOptions = CreateOptions<{ format: string | undefined }>; diff --git a/packages/cli/src/commands/mcp/index.ts b/packages/cli/src/commands/mcp/index.ts index c14c60307e..87b8b87f7a 100644 --- a/packages/cli/src/commands/mcp/index.ts +++ b/packages/cli/src/commands/mcp/index.ts @@ -20,6 +20,7 @@ const mcpCommand: Command = { ["lunora mcp uninstall --print", "Show what would be removed, without removing it"], ["lunora mcp serve", "Run the stdio MCP server (this is what your editor spawns)"], ["lunora mcp serve --allow-writes", "Also expose the mutation/action tools"], + ["lunora mcp serve --token $LUNORA_ADMIN_TOKEN", "Expose the observability tools (logs, issues, advisories, insights, migrations) too"], ], group: "Develop", loader: () => @@ -43,7 +44,12 @@ const mcpCommand: Command = { { description: "serve: skip the documentation tools", name: "no-docs", type: Boolean }, { description: "Docs site origin backing the documentation tools (default https://lunora.sh)", name: "docs-url", type: String }, { description: "serve: deployment URL to expose (default: the running dev server)", name: "url", type: String }, - { description: "serve: bearer token (default: LUNORA_ADMIN_TOKEN from the environment or .dev.vars)", name: "token", type: String }, + { + description: + "serve: bearer token (default: LUNORA_ADMIN_TOKEN from the environment or .dev.vars). Also gates the observability tools (logs, issues, advisories, query insights, migration status) — without a token they are not exposed at all", + name: "token", + type: String, + }, ], }; diff --git a/packages/cli/src/commands/verify/handler.ts b/packages/cli/src/commands/verify/handler.ts index 3283d41b8f..6cdd84fdd7 100644 --- a/packages/cli/src/commands/verify/handler.ts +++ b/packages/cli/src/commands/verify/handler.ts @@ -10,6 +10,8 @@ import type { CommandHandler } from "../../util/command"; import { defineHandler } from "../../util/command"; import { resolveTargetOrError } from "../../util/deploy-target"; import { detectPackageManager, execArgsFor } from "../../util/detect-package-manager"; +import type { HealthFetch } from "../../util/health-probe"; +import { probeHealth } from "../../util/health-probe"; import type { Logger } from "../../util/logger"; import { isJsonFormat, loggerForFormat, printJson, validateOutputFormat } from "../../util/output-format"; import { runSchemaDriftGate } from "../../util/schema-drift-gate"; @@ -18,12 +20,6 @@ import { defaultSpawner } from "../../util/spawn"; import { validateWrangler } from "../../util/wrangler-validator"; import type { VerifyOptions } from "./index"; -/** - * Minimal fetch surface the optional health probe needs — a subset of the global - * `fetch`, injectable so a test can feed a canned response without a network. - */ -type HealthFetch = (url: string) => Promise<{ ok: boolean; status: number }>; - interface VerifyCommandOptions { /** Override the schema-drift gate — report breaking drift as a warning instead of an error. */ allowSchemaDrift?: boolean; @@ -81,54 +77,29 @@ const runTypecheckStep = async (cwd: string, spawner: Spawner): Promise<{ error? return result.code === 0 ? {} : { error: `type errors: tsc --noEmit exited ${String(result.code)}` }; }; -/** The aggregate health route probed by the optional `--health-url` step. */ -const HEALTH_PATH = "/_lunora/health"; - -/** Join a base URL and the health path without doubling the slash. */ -const joinHealthUrl = (base: string): string => (base.endsWith("/") ? base.slice(0, -1) : base) + HEALTH_PATH; - -/** - * Probe a deployment's `GET /_lunora/health` when a `healthUrl` is supplied - * (opt-in — the step is skipped otherwise, keeping `verify` offline-safe by - * default). A `2xx` is green; a `503` (a critical dependency down) or any other - * non-`2xx`, and a transport failure, are red. Returns `{ error }` on red, an - * empty object on green. - */ -const runHealthProbeStep = async (healthUrl: string, healthFetch: HealthFetch): Promise<{ error?: string }> => { - const url = joinHealthUrl(healthUrl); - - let response: { ok: boolean; status: number }; - - try { - response = await healthFetch(url); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - - return { error: `health probe failed: could not reach ${url} (${message})` }; - } - - if (response.ok) { - return {}; - } - - return { error: `health probe failed: ${url} returned HTTP ${String(response.status)}` }; -}; - /** * Run the opt-in health probe when a `healthUrl` is supplied, logging a success * line on green. Returns the probe error on red, else `undefined`. Kept separate * from {@link runVerifyCommand} so its branching doesn't inflate that function's * cognitive complexity; the skip (no `healthUrl`) keeps `verify` offline-safe. + * + * Verify probes the AGGREGATE route once, with no retry: it validates a + * checkout rather than gating a release, so "is this deployment healthy right + * now" is the whole question — `deploy --health-check` is the one that waits + * for a fresh version to propagate. */ const probeHealthIfRequested = async (options: VerifyCommandOptions, logger: Logger): Promise => { if (options.healthUrl === undefined || options.healthUrl === "") { return undefined; } - const probe = await runHealthProbeStep(options.healthUrl, options.healthFetch ?? ((url) => fetch(url))); + const probe = await probeHealth({ baseUrl: options.healthUrl, fetchImpl: options.healthFetch }); if (probe.error === undefined) { - logger.success(`verify: health probe ok (${joinHealthUrl(options.healthUrl)})`); + // Report the URL the verdict actually came from. Rebuilding it here + // would agree with the probe only while `verify` keeps the default + // `paths` — the moment it passes more, the message names the wrong one. + logger.success(`verify: health probe ok (${probe.url})`); return undefined; } @@ -277,5 +248,5 @@ const execute: CommandHandler = defineHandler(asyn }); export { execute }; -export type { HealthFetch, VerifyCommandOptions, VerifyCommandResult }; +export type { VerifyCommandOptions, VerifyCommandResult }; export { runVerifyCommand }; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ee79cfabba..b88b69a532 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,7 +3,7 @@ export { COMMANDS, runCli, VERSION } from "./cli"; export { runCodegenCommand } from "./commands/codegen/handler"; export type { ExportCommandOptions, ExportCommandResult, ImportCommandOptions, ImportCommandResult, StreamingFetchLike } from "./commands/data-transfer"; export { DEFAULT_IMPORT_BATCH_SIZE, runExportCommand, runImportCommand } from "./commands/data-transfer"; -export type { DeployCommandOptions, DeployCommandResult } from "./commands/deploy/handler"; +export type { DeployCommandOptions, DeployCommandResult, DeployedIdentity } from "./commands/deploy/handler"; export { runDeployCommand } from "./commands/deploy/handler"; export type { DevCommandOptions, DevCommandPlan } from "./commands/dev/handler"; export { planDevCommand, runDevCommand } from "./commands/dev/handler"; diff --git a/packages/cli/src/util/auto-link.ts b/packages/cli/src/util/auto-link.ts index cbfaccc118..c99148a114 100644 --- a/packages/cli/src/util/auto-link.ts +++ b/packages/cli/src/util/auto-link.ts @@ -1,11 +1,17 @@ /** * Auto-link a checkout to its deployed Worker by parsing the URL out of * `wrangler deploy` output and writing `.lunora/project.json` — the zero-effort - * equivalent of running `lunora link` after the first deploy. + * equivalent of running `lunora link` after a deploy. * - * It only writes when the checkout is NOT already linked, so it never clobbers - * an explicit `lunora link`, and subsequent deploys keep wrangler's full TTY - * output (the caller only captures stdout for the first, unlinked deploy). + * Every real deploy re-checks the link, not just the first one: a URL that + * CHANGED (custom domain added, worker renamed, environment repointed) otherwise + * leaves a stale link that `run` / `logs` / `insights` / `deploy --migrate` then + * silently target. But an existing link is never silently rewritten — `lunora + * link` is an explicit user act — so a mismatch warns and keeps the recorded + * value, naming both URLs and the command that resolves it. + * + * The parser is Cloudflare-shaped (`*.workers.dev`, `wrangler deploy` stdout) + * and lives behind the Cloudflare deploy path; it is not a target-neutral seam. */ import { readLinkedProject, writeLinkedProject } from "@lunora/config"; @@ -22,7 +28,11 @@ const ANY_HTTPS_URL = /https:\/\/[^\s"'<>]+/u; * `*.workers.dev` origin, else the first https URL. Returns `undefined` when no * URL is present. */ -const parseDeployedUrl = (output: string): string | undefined => { +const parseDeployedUrl = (output: string | undefined): string | undefined => { + if (output === undefined) { + return undefined; + } + const workersDev = WORKERS_DEV_URL.exec(output); if (workersDev) { @@ -39,27 +49,45 @@ interface AutoLinkInputs { logger: Logger; /** Stamp written as `linkedAt`; injected in tests. */ now?: () => string; - /** Captured `wrangler deploy` stdout, or `undefined` when not captured. */ - output: string | undefined; + /** The deployed URL this run published to, or `undefined` when it couldn't be read. */ + url: string | undefined; } +/** `--env staging` → ` (--env staging)`; the top-level config has no suffix. */ +const environmentLabel = (env: string | undefined): string => (env === undefined ? "" : ` (--env ${env})`); + /** - * Write `.lunora/project.json` from a successful deploy's output, unless the - * checkout is already linked. Best-effort — never throws (a cosmetic - * convenience must not affect the deploy's exit code). + * Record the deployed URL in `.lunora/project.json`: + * + * - no link yet → write it; + * - a link recording the same URL for the same `--env` → nothing to do; + * - a link recording something else → WARN and keep the existing value. + * + * Best-effort — never throws (a convenience must not affect the deploy's exit + * code) and never called for a dry run, a preview, or a `--temporary` deploy, + * none of which have a durable URL worth recording. */ -const autoLinkFromDeployOutput = ({ cwd, env, logger, now, output }: AutoLinkInputs): void => { - if (output === undefined || readLinkedProject(cwd) !== undefined) { - return; - } - - const url = parseDeployedUrl(output); - +const autoLinkFromDeployOutput = ({ cwd, env, logger, now, url }: AutoLinkInputs): void => { if (url === undefined) { return; } try { + const existing = readLinkedProject(cwd); + + if (existing !== undefined) { + if (existing.workerUrl === url && existing.env === env) { + return; + } + + logger.warn( + `link: .lunora/project.json records ${existing.workerUrl ?? "(no url)"}${environmentLabel(existing.env)}, but this deploy published ${url}${environmentLabel(env)}. ` + + `Keeping the recorded value — run \`lunora link --url ${url}${env === undefined ? "" : ` --env ${env}`}\` to update it.`, + ); + + return; + } + const stamp = (now ?? (() => new Date().toISOString()))(); writeLinkedProject(cwd, { env, linkedAt: stamp, workerName: readWranglerName(cwd), workerUrl: url }); diff --git a/packages/cli/src/util/deploy-summary.ts b/packages/cli/src/util/deploy-summary.ts index 009355fac9..eae8b88129 100644 --- a/packages/cli/src/util/deploy-summary.ts +++ b/packages/cli/src/util/deploy-summary.ts @@ -27,6 +27,14 @@ interface DeploySummaryInputs { * scrollback from a log line that printed minutes earlier. */ mintedSecretsFile?: string; + + /** + * The URL this deploy published to, when it was read from wrangler's output. + * Preferred over the recorded link: the link can be stale (a custom domain + * added, the worker renamed) or absent entirely on a first deploy, while + * this value comes from the run that just finished. + */ + url?: string; } /** @@ -40,6 +48,7 @@ const renderDeploySummary = (inputs: DeploySummaryInputs): void => { try { const link = readLinkedProject(cwd); const workerName = link?.workerName ?? readWranglerName(cwd); + const url = inputs.url ?? link?.workerUrl; logger.success("deploy complete"); logger.info(` worker: ${workerName ?? "(see wrangler output above)"}`); @@ -48,10 +57,10 @@ const renderDeploySummary = (inputs: DeploySummaryInputs): void => { logger.info(` env: ${env}`); } - if (link?.workerUrl === undefined) { + if (url === undefined) { logger.info(" url: run `lunora link --url ` to record it"); } else { - logger.info(` url: ${link.workerUrl}`); + logger.info(` url: ${url}`); } if (migrated) { diff --git a/packages/cli/src/util/health-probe.ts b/packages/cli/src/util/health-probe.ts new file mode 100644 index 0000000000..1fe3ac02f5 --- /dev/null +++ b/packages/cli/src/util/health-probe.ts @@ -0,0 +1,142 @@ +/** + * The shared `/_lunora/health` probe used by `lunora verify --health-url` and + * `lunora deploy --health-check`. + * + * Both commands ask the same question — "does this deployment answer?" — so + * they ask it through one implementation with one error-message shape. The + * runtime auto-registers both routes (`packages/runtime/src/health-routes.ts`): + * `/_lunora/health/ready` is the readiness gate ("can this version serve"), and + * `/_lunora/health` is the aggregate that also exists on older deployments. + * + * The probe is transport-only: it never throws, and reports its verdict as an + * `{ error }` message the caller decides what to do with. + */ + +/** + * Minimal fetch surface the probe needs — a subset of the global `fetch`, + * injectable so a test can feed a canned response without a network. + */ +type HealthFetch = (url: string) => Promise<{ ok: boolean; status: number }>; + +/** The aggregate health route: reports every critical dependency (503 when one is down). */ +const HEALTH_PATH = "/_lunora/health"; + +/** The readiness gate: "can this version serve traffic". Absent on older deployments (404). */ +const HEALTH_READY_PATH = "/_lunora/health/ready"; + +/** Join a base URL and a health path without doubling the slash. */ +const joinHealthUrl = (base: string, path: string = HEALTH_PATH): string => (base.endsWith("/") ? base.slice(0, -1) : base) + path; + +interface HealthProbeInputs { + /** + * How many times to ask before giving up. Defaults to a single attempt (the + * `verify` behaviour); a fresh deploy passes more, because propagation makes + * one immediate probe a coin flip. + */ + attempts?: number; + /** The deployment's origin (with or without a trailing slash). */ + baseUrl: string; + /** Fixed delay between attempts, in ms. Not exponential — a predictable ceiling is what a CI timeout is set against. */ + delayMs?: number; + /** Injectable fetch; defaults to the global `fetch`. */ + fetchImpl?: HealthFetch; + + /** + * Health paths to try in order. A `404` on one falls through to the next + * (the route doesn't exist on that deployment); any other non-2xx is a real + * failure. Defaults to the aggregate route alone. + */ + paths?: ReadonlyArray; + /** Injectable clock for the inter-attempt delay; defaults to a real timer. */ + sleep?: (ms: number) => Promise; + + /** + * Per-attempt timeout, in ms. Applies only to the default fetch — an + * injected `fetchImpl` owns its own deadline. Without it a stalled + * connection would hang the probe indefinitely, since Node's `fetch` has no + * timeout of its own. + */ + timeoutMs?: number; +} + +interface HealthProbeResult { + /** The failure reason, absent when the probe is green. */ + error?: string; + /** The URL the verdict came from — the last one tried. */ + url: string; +} + +/** Try each path once, in order. A 404 falls through to the next path; anything else is the verdict. */ +const probeOnce = async (baseUrl: string, paths: ReadonlyArray, fetchImpl: HealthFetch): Promise => { + let last: HealthProbeResult = { error: `health probe failed: no health path configured for ${baseUrl}`, url: baseUrl }; + + for (const [index, path] of paths.entries()) { + const url = joinHealthUrl(baseUrl, path); + let response: { ok: boolean; status: number }; + + try { + // eslint-disable-next-line no-await-in-loop -- sequential fallback: the next path is only tried when this one 404s + response = await fetchImpl(url); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + + return { error: `health probe failed: could not reach ${url} (${message})`, url }; + } + + if (response.ok) { + return { url }; + } + + last = { error: `health probe failed: ${url} returned HTTP ${String(response.status)}`, url }; + + // A 404 means this deployment doesn't mount that route (an older + // runtime has no readiness gate) — fall through to the next path. + if (response.status !== 404 || index === paths.length - 1) { + return last; + } + } + + return last; +}; + +const realSleep = async (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +/** + * Probe a deployment's health route(s), retrying up to `attempts` times with a + * fixed `delayMs` between tries. A `2xx` is green; a `503` (a critical + * dependency down), any other non-2xx, and a transport failure are red. Returns + * the last verdict — `{ url }` on green, `{ error, url }` on red. + */ +const probeHealth = async ({ + attempts = 1, + baseUrl, + delayMs = 2000, + fetchImpl, + paths = [HEALTH_PATH], + sleep = realSleep, + timeoutMs = 10_000, +}: HealthProbeInputs): Promise => { + // Node's `fetch` never times out on its own: a worker that accepts the + // connection and then goes quiet would hang the probe — and with it + // `deploy --health-check` — until CI's job timeout killed it. That defeats + // the whole point of a fixed attempt budget, so bound every attempt. An + // abort surfaces as a transport error, which `probeOnce` already reds. + const doFetch = fetchImpl ?? ((url: string) => fetch(url, { signal: AbortSignal.timeout(timeoutMs) })); + const budget = Math.max(1, attempts); + let result = await probeOnce(baseUrl, paths, doFetch); + + for (let attempt = 1; attempt < budget && result.error !== undefined; attempt += 1) { + // eslint-disable-next-line no-await-in-loop -- a retry is sequential by definition + await sleep(delayMs); + // eslint-disable-next-line no-await-in-loop -- same + result = await probeOnce(baseUrl, paths, doFetch); + } + + return result; +}; + +export type { HealthFetch, HealthProbeInputs, HealthProbeResult }; +export { HEALTH_PATH, HEALTH_READY_PATH, joinHealthUrl, probeHealth }; diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 18a27cc388..7a0eeed2d8 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -45,16 +45,21 @@ Part of the [Lunora](https://github.com/anolilab/lunora) framework — a type-sa ## Tools -| Tool | Description | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `lunora_list_functions` | List the deployment's public functions (queries, mutations, actions) with their kinds. | -| `lunora_list_tables` | List the deployment's `.global()` tables with their row counts. | -| `lunora_get_function_schema` | Return a function's argument descriptors and kind by path, so a caller can construct a valid arguments object. | -| `lunora_run_query` | Run a query and return its result. Read-only. | -| `lunora_run_mutation` | Run a mutation and return its result. Writes data — use with care. | -| `lunora_run_action` | Run an action and return its result. May call external services. | -| `agent_` | Start a durable [`@lunora/agent`](https://www.npmjs.com/package/@lunora/agent) run and await its answer. One tool per exposed agent. Requires agents enabled. | -| `lunora_agent_status` | Poll a running agent by `threadKey` and return its answer once finished. Requires agents enabled. | +| Tool | Description | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `lunora_list_functions` | List the deployment's public functions (queries, mutations, actions) with their kinds. | +| `lunora_list_tables` | List the deployment's `.global()` tables with their row counts. | +| `lunora_get_function_schema` | Return a function's argument descriptors and kind by path, so a caller can construct a valid arguments object. | +| `lunora_run_query` | Run a query and return its result. Read-only. | +| `lunora_run_mutation` | Run a mutation and return its result. Writes data — use with care. | +| `lunora_run_action` | Run an action and return its result. May call external services. | +| `lunora_get_logs` | Read the deployment's recent log entries (newest first). Requires an admin token. | +| `lunora_get_issues` | List errors grouped into Issues by fingerprint, with counts and triage status. Requires an admin token. | +| `lunora_get_advisories` | List the deployment's schema/query advisories. Requires an admin token. | +| `lunora_get_query_insights` | Per-statement execution counts and latency over a recent window. Requires an admin token. | +| `lunora_get_migration_status` | Which migrations are applied and which are pending. Requires an admin token. | +| `agent_` | Start a durable [`@lunora/agent`](https://www.npmjs.com/package/@lunora/agent) run and await its answer. One tool per exposed agent. Requires agents enabled. | +| `lunora_agent_status` | Poll a running agent by `threadKey` and return its answer once finished. Requires agents enabled. | ### Recommended agent flow @@ -65,6 +70,25 @@ Part of the [Lunora](https://github.com/anolilab/lunora) framework — a type-sa → call the function with a well-formed arguments object ``` +### Observability tools (privileged) + +The five `lunora_get_*` observability tools are read-only, but they surface the +deployment's **operational data** — log lines, request metadata, and grouped +error messages, all of which may contain user data, and all of which land in the +model's context (and therefore at its provider). They are exposed **only when an +admin token resolved**: without one they are omitted from `ListTools` entirely +and refused at dispatch, the same omit-don't-refuse rule the write tools use. +They are independent of `--allow-writes`, which is about changing data, not +reading operational data. + +They return `structuredContent` alongside the usual text block, described by each +tool's `outputSchema` (MCP revision `2025-06-18` and later; older clients keep +reading the text block). All but `lunora_get_migration_status` take a `limit` +clamped server-side; migration status takes only `shardKey` and returns every +migration, because truncating that list would hide the pending one. Each also +takes an optional `shardKey` — on a `.shardBy()`-partitioned deployment these +reads are **per-shard**, not deployment-wide. + `lunora_get_function_schema` returns a JSON object with three fields: - `path` — the function path (e.g. `"messages:send"`) diff --git a/packages/mcp/__tests__/local.test.ts b/packages/mcp/__tests__/local.test.ts index 7df6fb9abc..52a08047ea 100644 --- a/packages/mcp/__tests__/local.test.ts +++ b/packages/mcp/__tests__/local.test.ts @@ -106,6 +106,32 @@ describe("localTools", () => { expect(namesOf(localTools({ allowWrites: true, deployment: () => undefined, docs: false }))).toContain("lunora_run_mutation"); }); + it("hides the observability tools unless the resolved deployment carries an admin token", () => { + expect.assertions(3); + + expect(namesOf(localTools({ deployment: () => undefined, docs: false }))).not.toContain("lunora_get_logs"); + expect(namesOf(localTools({ deployment: { url: "https://worker.example" }, docs: false }))).not.toContain("lunora_get_logs"); + expect(namesOf(localTools({ deployment: { token: "admin-token", url: "https://worker.example" }, docs: false }))).toContain("lunora_get_logs"); + }); + + it("refuses an observability tool at dispatch when the resolved deployment has no token", async () => { + expect.assertions(2); + + const { asFetch, urls } = stubFetch(); + // Listed because a token was present at build time, then withdrawn — the + // dispatch-side check is what still refuses the call. + let deployment: LocalDeployment | undefined = { token: "admin-token", url: "https://worker.example" }; + const tools = localTools({ deployment: () => deployment, docs: false, fetch: asFetch }); + const logs = tools.find((tool) => tool.definition.name === "lunora_get_logs"); + + deployment = { url: "https://worker.example" }; + + const result = await logs?.handle({}); + + expect(result?.isError).toBe(true); + expect(urls).toStrictEqual([]); + }); + it("tells the caller to start the dev server when a deployment tool is used with none running", async () => { expect.assertions(2); diff --git a/packages/mcp/__tests__/observability-tools.test.ts b/packages/mcp/__tests__/observability-tools.test.ts new file mode 100644 index 0000000000..fc98308c1f --- /dev/null +++ b/packages/mcp/__tests__/observability-tools.test.ts @@ -0,0 +1,323 @@ +import type { FunctionDescriptor, LunoraClient } from "@lunora/client"; +import { ADMIN_FUNCTIONS } from "@lunora/shard-engine"; +import { describe, expect, it, vi } from "vitest"; + +import { DEFAULT_LIMIT, MAX_LIMIT, OBSERVABILITY_TOOL_DEFINITIONS } from "../src/observability-tools"; +import { callTool, toolDefinitions } from "../src/tools"; + +const OBSERVABILITY_NAMES = ["lunora_get_logs", "lunora_get_issues", "lunora_get_advisories", "lunora_get_query_insights", "lunora_get_migration_status"]; + +/** A log ring entry, as `__lunora_admin__:getLogs` returns it (newest first). */ +const logEntry = (index: number, level: string): Record => { + return { level, message: `line ${index.toString()}`, timestamp: 1000 + index }; +}; + +/** + * Mock client whose `query` answers per admin op path, so a test asserts the + * op the tool chose rather than a positional call index. + */ +const mockClient = ( + results: Record = {}, +): { + asClient: LunoraClient; + query: ReturnType; +} => { + const query = vi.fn<(reference: { __lunoraRef: string }) => Promise>(async (reference) => results[reference.__lunoraRef] ?? {}); + const listFunctions = vi.fn<() => Promise>(async () => []); + + return { asClient: { listFunctions, query } as unknown as LunoraClient, query }; +}; + +describe("observability tool definitions", () => { + it("declares five tools, each read-only and carrying an outputSchema", () => { + expect.assertions(3); + + expect(OBSERVABILITY_TOOL_DEFINITIONS.map((tool) => tool.name)).toStrictEqual(OBSERVABILITY_NAMES); + expect(OBSERVABILITY_TOOL_DEFINITIONS.every((tool) => tool.annotations?.readOnlyHint === true)).toBe(true); + // The MCP spec requires an output schema to be an object at the root. + expect(OBSERVABILITY_TOOL_DEFINITIONS.every((tool) => tool.outputSchema?.type === "object")).toBe(true); + }); + + it("gives every tool a title and a description that says when to call it", () => { + expect.assertions(2); + + expect(OBSERVABILITY_TOOL_DEFINITIONS.every((tool) => (tool.annotations?.title ?? "").length > 0)).toBe(true); + expect(OBSERVABILITY_TOOL_DEFINITIONS.every((tool) => tool.description.length > 40)).toBe(true); + }); +}); + +describe("token-tier gating", () => { + it("omits the observability tools from the advertised list when no admin token resolved", () => { + expect.assertions(2); + + const names = toolDefinitions(false).map((tool) => tool.name); + + expect(names).toStrictEqual(["lunora_list_functions", "lunora_list_tables", "lunora_get_function_schema", "lunora_run_query"]); + expect(names.some((name) => OBSERVABILITY_NAMES.includes(name))).toBe(false); + }); + + it("advertises them once a token resolved, without disturbing the write tier", () => { + expect.assertions(2); + + expect(toolDefinitions(false, true).map((tool) => tool.name)).toStrictEqual([ + "lunora_list_functions", + "lunora_list_tables", + "lunora_get_function_schema", + "lunora_run_query", + ...OBSERVABILITY_NAMES, + ]); + expect(toolDefinitions(true, true).map((tool) => tool.name)).toStrictEqual([ + "lunora_list_functions", + "lunora_list_tables", + "lunora_get_function_schema", + "lunora_run_query", + ...OBSERVABILITY_NAMES, + "lunora_run_mutation", + "lunora_run_action", + ]); + }); + + it("keeps the token tier independent of the write tier", () => { + expect.assertions(1); + + // `--allow-writes` must not smuggle in the privileged reads. + expect(toolDefinitions(true).map((tool) => tool.name)).toStrictEqual([ + "lunora_list_functions", + "lunora_list_tables", + "lunora_get_function_schema", + "lunora_run_query", + "lunora_run_mutation", + "lunora_run_action", + ]); + }); + + it.each(OBSERVABILITY_NAMES)("refuses %s at dispatch when no admin token resolved, without touching the client", async (name) => { + expect.assertions(3); + + const mock = mockClient(); + const result = await callTool(mock.asClient, name, {}); + + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toContain("admin token"); + expect(mock.query).not.toHaveBeenCalled(); + }); + + it("refuses at dispatch even when writes are enabled but no token resolved", async () => { + expect.assertions(2); + + const mock = mockClient(); + const result = await callTool(mock.asClient, "lunora_get_logs", {}, true); + + expect(result.isError).toBe(true); + expect(mock.query).not.toHaveBeenCalled(); + }); +}); + +describe("lunora_get_logs", () => { + it("reads the getLogs admin op and returns the newest entries", async () => { + expect.assertions(4); + + const entries = [logEntry(1, "error"), logEntry(2, "info"), logEntry(3, "warn")]; + const mock = mockClient({ [ADMIN_FUNCTIONS.getLogs]: { entries } }); + const result = await callTool(mock.asClient, "lunora_get_logs", { limit: 2 }, false, true); + + expect(mock.query).toHaveBeenCalledWith({ __lunoraRef: ADMIN_FUNCTIONS.getLogs }, {}, {}); + expect(result.isError).toBeUndefined(); + expect(result.structuredContent).toStrictEqual({ entries: [entries[0], entries[1]], total: 3 }); + // The text block stays, for a client on a pre-2025-06-18 revision. + expect(JSON.parse(result.content[0]!.text)).toStrictEqual(result.structuredContent); + }); + + it("filters by level before limiting, and reports the pre-limit total", async () => { + expect.assertions(2); + + const entries = [logEntry(1, "info"), logEntry(2, "error"), logEntry(3, "error")]; + const mock = mockClient({ [ADMIN_FUNCTIONS.getLogs]: { entries } }); + const result = await callTool(mock.asClient, "lunora_get_logs", { level: "error", limit: 1 }, false, true); + + expect(result.structuredContent).toStrictEqual({ entries: [entries[1]], total: 2 }); + expect((result.structuredContent as { entries: unknown[] }).entries).toHaveLength(1); + }); + + it("ignores an unrecognized level rather than filtering everything out", async () => { + expect.assertions(1); + + const entries = [logEntry(1, "info")]; + const mock = mockClient({ [ADMIN_FUNCTIONS.getLogs]: { entries } }); + const result = await callTool(mock.asClient, "lunora_get_logs", { level: "LOUD" }, false, true); + + expect(result.structuredContent).toStrictEqual({ entries, total: 1 }); + }); + + it("forwards a shardKey, since these reads are per-shard", async () => { + expect.assertions(1); + + const mock = mockClient({ [ADMIN_FUNCTIONS.getLogs]: { entries: [] } }); + + await callTool(mock.asClient, "lunora_get_logs", { shardKey: "room-1" }, false, true); + + expect(mock.query).toHaveBeenCalledWith({ __lunoraRef: ADMIN_FUNCTIONS.getLogs }, {}, { shardKey: "room-1" }); + }); + + it("survives a deployment that answers with no entries array", async () => { + expect.assertions(2); + + const mock = mockClient({ [ADMIN_FUNCTIONS.getLogs]: {} }); + const result = await callTool(mock.asClient, "lunora_get_logs", {}, false, true); + + expect(result.isError).toBeUndefined(); + expect(result.structuredContent).toStrictEqual({ entries: [], total: 0 }); + }); + + it("maps a bigint / bytes leaf so structuredContent survives the transport's JSON.stringify", async () => { + expect.assertions(3); + + // `LunoraClient` decodes every response, so a `v.int64()` field logged + // into `fields` reaches the tool as a real bigint. Left unmapped it + // would throw when the transport serializes `structuredContent`. + const entries = [{ fields: { blob: new Uint8Array([1, 2, 3]), id: 9_007_199_254_740_993n }, level: "info", message: "m", timestamp: 1 }]; + const mock = mockClient({ [ADMIN_FUNCTIONS.getLogs]: { entries } }); + const result = await callTool(mock.asClient, "lunora_get_logs", {}, false, true); + + expect(result.isError).toBeUndefined(); + expect(() => JSON.stringify(result.structuredContent)).not.toThrow(); + expect(result.structuredContent).toStrictEqual({ + entries: [{ fields: { blob: "AQID", id: "9007199254740993" }, level: "info", message: "m", timestamp: 1 }], + total: 1, + }); + }); +}); + +describe("limit clamping", () => { + const manyEntries = Array.from({ length: MAX_LIMIT + 25 }, (_, index) => logEntry(index, "info")); + + it("defaults a missing limit", async () => { + expect.assertions(1); + + const mock = mockClient({ [ADMIN_FUNCTIONS.getLogs]: { entries: manyEntries } }); + const result = await callTool(mock.asClient, "lunora_get_logs", {}, false, true); + + expect((result.structuredContent as { entries: unknown[] }).entries).toHaveLength(DEFAULT_LIMIT); + }); + + it("clamps a limit above the ceiling", async () => { + expect.assertions(1); + + const mock = mockClient({ [ADMIN_FUNCTIONS.getLogs]: { entries: manyEntries } }); + const result = await callTool(mock.asClient, "lunora_get_logs", { limit: 100_000 }, false, true); + + expect((result.structuredContent as { entries: unknown[] }).entries).toHaveLength(MAX_LIMIT); + }); + + it("clamps a zero/negative limit up to one", async () => { + expect.assertions(2); + + const mock = mockClient({ [ADMIN_FUNCTIONS.getIssues]: { issues: [] } }); + + await callTool(mock.asClient, "lunora_get_issues", { limit: 0 }, false, true); + await callTool(mock.asClient, "lunora_get_issues", { limit: -5 }, false, true); + + expect(mock.query).toHaveBeenNthCalledWith(1, { __lunoraRef: ADMIN_FUNCTIONS.getIssues }, { limit: 1 }, {}); + expect(mock.query).toHaveBeenNthCalledWith(2, { __lunoraRef: ADMIN_FUNCTIONS.getIssues }, { limit: 1 }, {}); + }); + + it("falls back to the default for a non-numeric limit", async () => { + expect.assertions(1); + + const mock = mockClient({ [ADMIN_FUNCTIONS.getIssues]: { issues: [] } }); + + await callTool(mock.asClient, "lunora_get_issues", { limit: "lots" }, false, true); + + expect(mock.query).toHaveBeenCalledWith({ __lunoraRef: ADMIN_FUNCTIONS.getIssues }, { limit: DEFAULT_LIMIT }, {}); + }); +}); + +describe("lunora_get_issues", () => { + it("pushes limit/status/functionPathPrefix down to the RPC so grouping sees the right rows", async () => { + expect.assertions(2); + + const issues = [{ count: 3, hash: "abc", title: "boom" }]; + const mock = mockClient({ [ADMIN_FUNCTIONS.getIssues]: { issues } }); + const result = await callTool(mock.asClient, "lunora_get_issues", { functionPathPrefix: "messages:", limit: 10, status: "open" }, false, true); + + expect(mock.query).toHaveBeenCalledWith({ __lunoraRef: ADMIN_FUNCTIONS.getIssues }, { functionPathPrefix: "messages:", limit: 10, status: "open" }, {}); + expect(result.structuredContent).toStrictEqual({ issues }); + }); + + it("drops an unrecognized status instead of forwarding it", async () => { + expect.assertions(1); + + const mock = mockClient({ [ADMIN_FUNCTIONS.getIssues]: { issues: [] } }); + + await callTool(mock.asClient, "lunora_get_issues", { status: "on-fire" }, false, true); + + expect(mock.query).toHaveBeenCalledWith({ __lunoraRef: ADMIN_FUNCTIONS.getIssues }, { limit: DEFAULT_LIMIT }, {}); + }); +}); + +describe("the remaining reads", () => { + it("lunora_get_advisories limits and reports the total", async () => { + expect.assertions(2); + + const advisories = [{ id: "a" }, { id: "b" }, { id: "c" }]; + const mock = mockClient({ [ADMIN_FUNCTIONS.getAdvisories]: { advisories } }); + const result = await callTool(mock.asClient, "lunora_get_advisories", { limit: 2 }, false, true); + + expect(mock.query).toHaveBeenCalledWith({ __lunoraRef: ADMIN_FUNCTIONS.getAdvisories }, {}, {}); + expect(result.structuredContent).toStrictEqual({ advisories: [{ id: "a" }, { id: "b" }], total: 3 }); + }); + + it("lunora_get_query_insights forwards a known range and passes the series through", async () => { + expect.assertions(2); + + const insights = { buckets: [{ bucketMs: 1 }], capped: true, entries: [{ sql: "select 1" }], trackedStatements: 42 }; + const mock = mockClient({ [ADMIN_FUNCTIONS.getQueryInsights]: insights }); + const result = await callTool(mock.asClient, "lunora_get_query_insights", { range: "1h" }, false, true); + + expect(mock.query).toHaveBeenCalledWith({ __lunoraRef: ADMIN_FUNCTIONS.getQueryInsights }, { range: "1h" }, {}); + expect(result.structuredContent).toStrictEqual({ + buckets: insights.buckets, + capped: true, + entries: insights.entries, + total: 1, + trackedStatements: 42, + }); + }); + + it("lunora_get_query_insights drops an unknown range so the RPC applies its own default", async () => { + expect.assertions(1); + + const mock = mockClient({ [ADMIN_FUNCTIONS.getQueryInsights]: { buckets: [], entries: [] } }); + + await callTool(mock.asClient, "lunora_get_query_insights", { range: "forever" }, false, true); + + expect(mock.query).toHaveBeenCalledWith({ __lunoraRef: ADMIN_FUNCTIONS.getQueryInsights }, {}, {}); + }); + + it("lunora_get_migration_status returns every migration, untruncated", async () => { + expect.assertions(2); + + const migrations = Array.from({ length: DEFAULT_LIMIT + 10 }, (_, index) => { + return { applied: index % 2 === 0, id: index.toString() }; + }); + const mock = mockClient({ [ADMIN_FUNCTIONS.migrationStatus]: { migrations } }); + const result = await callTool(mock.asClient, "lunora_get_migration_status", {}, false, true); + + expect(mock.query).toHaveBeenCalledWith({ __lunoraRef: ADMIN_FUNCTIONS.migrationStatus }, {}, {}); + // Truncating this list would hide exactly the pending migration asked about. + expect((result.structuredContent as { migrations: unknown[] }).migrations).toHaveLength(migrations.length); + }); + + it("surfaces a failed admin read as an error result rather than rejecting", async () => { + expect.assertions(2); + + const mock = mockClient(); + + mock.query.mockRejectedValueOnce(new Error("ADMIN_FORBIDDEN")); + + const result = await callTool(mock.asClient, "lunora_get_logs", {}, false, true); + + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toContain("ADMIN_FORBIDDEN"); + }); +}); diff --git a/packages/mcp/__tests__/server.test.ts b/packages/mcp/__tests__/server.test.ts index 1e78e30f8c..7770ed0091 100644 --- a/packages/mcp/__tests__/server.test.ts +++ b/packages/mcp/__tests__/server.test.ts @@ -104,6 +104,67 @@ describe("createLunoraMcpServer request handlers", () => { ]); }); + it("listTools omits the observability tools when no admin token was configured", async () => { + expect.assertions(1); + + const server = createLunoraMcpServer({ allowWrites: true, client: mockClient().asClient }); + const result = (await handlerFor(server, ListToolsRequestSchema.shape.method.value)({})) as ListToolsResult; + + // Privileged reads must not even be advertised on an unauthenticated + // server — `--allow-writes` is a separate axis and must not imply them. + expect(result.tools.some((tool) => tool.name.startsWith("lunora_get_") && tool.name !== "lunora_get_function_schema")).toBe(false); + }); + + it("listTools includes the observability tools once a token is configured", async () => { + expect.assertions(2); + + const server = createLunoraMcpServer({ client: mockClient().asClient, token: "admin-token" }); + const result = (await handlerFor(server, ListToolsRequestSchema.shape.method.value)({})) as ListToolsResult; + + expect(result.tools.map((tool) => tool.name)).toContain("lunora_get_logs"); + // The SDK's Tool schema carries `outputSchema`, so the declaration + // survives the handler rather than being dropped as an unknown key. + expect(result.tools.find((tool) => tool.name === "lunora_get_logs")?.outputSchema).toBeDefined(); + }); + + it("refuses an observability call fail-closed when no token was configured", async () => { + expect.assertions(2); + + const mock = mockClient(); + // The tool isn't advertised, but a client could still name it — dispatch must refuse. + const server = createLunoraMcpServer({ client: mock.asClient }); + + const result = (await handlerFor( + server, + CallToolRequestSchema.shape.method.value, + )({ + params: { arguments: {}, name: "lunora_get_logs" }, + })) as CallToolResult; + + expect(result.isError).toBe(true); + expect(mock.query).not.toHaveBeenCalled(); + }); + + it("dispatches an observability call and returns structuredContent alongside the text block", async () => { + expect.assertions(3); + + const mock = mockClient(); + + mock.query.mockResolvedValueOnce({ entries: [{ level: "info", message: "hello", timestamp: 1 }] }); + + const server = createLunoraMcpServer({ client: mock.asClient, token: "admin-token" }); + const result = (await handlerFor( + server, + CallToolRequestSchema.shape.method.value, + )({ + params: { arguments: {}, name: "lunora_get_logs" }, + })) as CallToolResult; + + expect(mock.query).toHaveBeenCalledTimes(1); + expect(result.structuredContent).toStrictEqual({ entries: [{ level: "info", message: "hello", timestamp: 1 }], total: 1 }); + expect(JSON.parse((result.content[0] as { text: string }).text)).toStrictEqual(result.structuredContent); + }); + it("callTool dispatches through callTool against the injected client", async () => { expect.assertions(2); diff --git a/packages/mcp/__tests__/tools.test.ts b/packages/mcp/__tests__/tools.test.ts index fcb444fd1e..9ffa07b594 100644 --- a/packages/mcp/__tests__/tools.test.ts +++ b/packages/mcp/__tests__/tools.test.ts @@ -54,7 +54,7 @@ const mockClient = (): { }; describe("toolDefinitions", () => { - it("exposes only the four read-only tools by default (writes disabled)", () => { + it("exposes only the four read-only tools by default (writes disabled, no admin token)", () => { expect.assertions(2); const names = toolDefinitions(false).map((tool) => tool.name); diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 1c2d168a08..276df242e8 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -69,6 +69,7 @@ "dependencies": { "@lunora/client": "1.0.0-alpha.43", "@lunora/errors": "1.0.0-alpha.16", + "@lunora/shard-engine": "1.0.0-alpha.16", "@modelcontextprotocol/sdk": "catalog:mcp" }, "devDependencies": { diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 7ecfdc4feb..1603b1cbb7 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -6,8 +6,12 @@ * The deployment server: It registers tools for introspecting a deployment * (`lunora_list_functions`, `lunora_list_tables`) and invoking its functions * (`lunora_run_query`, plus `lunora_run_mutation` and `lunora_run_action` when - * writes are enabled), each backed by `LunoraClient` over HTTP RPC. The server - * is read-only by default — the write tools are exposed only when `allowWrites` + * writes are enabled), each backed by `LunoraClient` over HTTP RPC. It also + * exposes the deployment's observability reads (`lunora_get_logs`, + * `lunora_get_issues`, `lunora_get_advisories`, `lunora_get_query_insights`, + * `lunora_get_migration_status`) whenever an admin token resolved — read-only, + * but privileged, so they are omitted entirely without one. The server is + * read-only by default — the write tools are exposed only when `allowWrites` * (or the `LUNORA_MCP_ALLOW_WRITES` env) is set, and every run tool is * allowlisted against the deployment's discovered public functions. It can also * front durable `@lunora/agent` runs as `agent_` tools when `allowAgents` @@ -31,4 +35,4 @@ export { createPaidMcpServer } from "./paid"; export type { LunoraMcpServerOptions } from "./server"; export { connectStdio, createLunoraMcpServer } from "./server"; export type { ToolDefinition, ToolInputSchema, ToolResult } from "./tools"; -export { callTool, READ_ONLY_TOOL_DEFINITIONS, toolDefinitions, WRITE_TOOL_DEFINITIONS } from "./tools"; +export { callTool, OBSERVABILITY_TOOL_DEFINITIONS, READ_ONLY_TOOL_DEFINITIONS, toolDefinitions, WRITE_TOOL_DEFINITIONS } from "./tools"; diff --git a/packages/mcp/src/local.ts b/packages/mcp/src/local.ts index 8d210a0abd..7b9caaa2af 100644 --- a/packages/mcp/src/local.ts +++ b/packages/mcp/src/local.ts @@ -148,6 +148,9 @@ const createClientCache = (fetchImplementation: typeof fetch | undefined): ((dep }; }; +/** True when `deployment` carries a usable admin bearer — the observability tools' gate. */ +const hasAdminToken = (deployment: LocalDeployment | undefined): boolean => deployment?.token !== undefined && deployment.token.length > 0; + /** * The deployment tools, resolving their target at dispatch time. * @@ -156,6 +159,13 @@ const createClientCache = (fetchImplementation: typeof fetch | undefined): ((dep * happened to be up at startup would stay invisible for the rest of the * session. Calling one while nothing is running returns an actionable error * instead. + * + * The observability tools are the ONE exception: they read the deployment's + * logs and grouped errors, so an unauthenticated server must not advertise that + * they exist. Their gate is therefore a snapshot taken here, at build time — + * fail-closed, at the cost of a session that started before the dev server not + * seeing them. Dispatch re-checks the token as freshly resolved, so a listed + * tool whose token has since gone is still refused. */ const lazyDeploymentTools = ( source: LocalDeploymentSource, @@ -164,7 +174,7 @@ const lazyDeploymentTools = ( ): ReadonlyArray => { const resolve = typeof source === "function" ? source : (): LocalDeployment => source; - return toolDefinitions(allowWrites).map((definition) => { + return toolDefinitions(allowWrites, hasAdminToken(resolve())).map((definition) => { return { definition, handle: async (input: Record): Promise => { @@ -174,7 +184,7 @@ const lazyDeploymentTools = ( return { content: [{ text: NO_DEPLOYMENT_MESSAGE, type: "text" }], isError: true }; } - return callTool(clientFor(deployment), definition.name, input, allowWrites); + return callTool(clientFor(deployment), definition.name, input, allowWrites, hasAdminToken(deployment)); }, }; }); diff --git a/packages/mcp/src/observability-tools.ts b/packages/mcp/src/observability-tools.ts new file mode 100644 index 0000000000..c98abd4d66 --- /dev/null +++ b/packages/mcp/src/observability-tools.ts @@ -0,0 +1,319 @@ +/** + * The observability tool surface: the reads an agent needs to answer "did my + * change work, and what broke" — recent logs, grouped error Issues, schema + * advisories, query insights, and migration status. + * + * A THIRD tier, distinct from the always-on read tools and the `allowWrites` + * write tools. These are read-only in the `readOnlyHint` sense, but they + * surface production logs, request metadata, and grouped errors, so they are + * advertised ONLY when an admin token resolved — the same omit-don't-refuse + * rule the write gate uses, plus a refusal at dispatch. See `./tools`, which + * owns both halves of that gate. + * + * Every read is an existing `__lunora_admin__:*` RPC, reached through the same + * `LunoraClient` (and therefore the same `/_lunora/rpc` transport and bearer) + * the other tools use. The op paths come from `ADMIN_FUNCTIONS`; a hand-written + * `"__lunora_admin__:…"` literal is how a renamed op ships a 404 to one + * consumer and not another. + */ +import type { FunctionReference, LunoraClient } from "@lunora/client"; +import { LunoraError } from "@lunora/errors"; +import { ADMIN_FUNCTIONS } from "@lunora/shard-engine"; + +import { okStructured } from "./tool-result"; +import type { ToolDefinition, ToolInputSchema, ToolResult } from "./tool-types"; + +/** Introspection reads touch no state; every call goes to the deployment. */ +const READ_ONLY_ANNOTATIONS = { destructiveHint: false, idempotentHint: true, openWorldHint: true, readOnlyHint: true } as const; + +/** + * Rows returned when the caller passes no `limit`, and the ceiling a larger one + * is clamped to. + * + * Deliberately far below the admin RPCs' own bounds (`getIssues` clamps to + * 10000; the log ring holds 500): every row lands in a model's context window + * and is paid for on every subsequent turn, so the binding constraint here is + * context, not the datastore. + */ +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 500; + +/** The `range` tokens `getQueryInsights` understands; anything else falls back to its 15m default. */ +const INSIGHT_RANGES = ["1m", "5m", "15m", "1h"] as const; + +/** Triage states `getIssues` filters on. */ +const ISSUE_STATUSES = ["open", "resolved", "ignored"] as const; + +/** Log severities `ctx.log.*` records. */ +const LOG_LEVELS = ["trace", "debug", "log", "info", "warn", "error", "fatal"] as const; + +/** + * A shard key every observability tool accepts. The reads are served by the + * shard that handled the traffic, so on a `.shardBy()`-partitioned deployment + * "the logs" are per-shard: reading one shard while presenting it as the whole + * deployment would be actively misleading. + */ +const SHARD_KEY_PROPERTY = { + description: + "Shard to read from on a .shardBy()-partitioned deployment. Omit for the default (unsharded) shard — these reads are PER-SHARD, not deployment-wide.", + type: "string", +} as const; + +const LIMIT_PROPERTY = { + description: `Maximum rows to return (default ${DEFAULT_LIMIT.toString()}, clamped to ${MAX_LIMIT.toString()}).`, + type: "number", +} as const; + +/** Clamp a caller-supplied `limit` into `[1, MAX_LIMIT]`, defaulting a missing/invalid one. */ +const readLimit = (raw: unknown): number => { + const value = typeof raw === "number" ? raw : Number.NaN; + + if (!Number.isFinite(value)) { + return DEFAULT_LIMIT; + } + + return Math.max(1, Math.min(Math.floor(value), MAX_LIMIT)); +}; + +/** Pass an input string through only when it is one of `allowed`; anything else is dropped (the RPC's own default applies). */ +const readEnum = (raw: unknown, allowed: ReadonlyArray): T | undefined => (allowed.includes(raw as T) ? (raw as T) : undefined); + +/** A non-empty input string, or `undefined`. */ +const readText = (raw: unknown): string | undefined => (typeof raw === "string" && raw.length > 0 ? raw : undefined); + +const LOGS_INPUT_SCHEMA: ToolInputSchema = { + properties: { + level: { description: `Keep only entries at this severity. One of: ${LOG_LEVELS.join(", ")}.`, type: "string" }, + limit: LIMIT_PROPERTY, + shardKey: SHARD_KEY_PROPERTY, + }, + type: "object", +}; + +const ISSUES_INPUT_SCHEMA: ToolInputSchema = { + properties: { + functionPathPrefix: { description: 'Keep only Issues whose function path starts with this, e.g. "messages:".', type: "string" }, + limit: LIMIT_PROPERTY, + shardKey: SHARD_KEY_PROPERTY, + status: { description: `Triage status to keep. One of: ${ISSUE_STATUSES.join(", ")}. Default: all.`, type: "string" }, + }, + type: "object", +}; + +const ADVISORIES_INPUT_SCHEMA: ToolInputSchema = { + properties: { limit: LIMIT_PROPERTY, shardKey: SHARD_KEY_PROPERTY }, + type: "object", +}; + +const INSIGHTS_INPUT_SCHEMA: ToolInputSchema = { + properties: { + limit: LIMIT_PROPERTY, + range: { description: `Time window to report over. One of: ${INSIGHT_RANGES.join(", ")}. Default: 15m.`, type: "string" }, + shardKey: SHARD_KEY_PROPERTY, + }, + type: "object", +}; + +const MIGRATION_STATUS_INPUT_SCHEMA: ToolInputSchema = { + properties: { shardKey: SHARD_KEY_PROPERTY }, + type: "object", +}; + +/** + * `outputSchema`s are shallow on purpose: they tell a client what the top-level + * envelope is (and let it validate `structuredContent`), without pinning row + * shapes that the admin RPCs are free to extend. A row schema copied here is a + * second source of truth that goes stale silently. + */ +const LOGS_OUTPUT_SCHEMA: ToolInputSchema = { + properties: { + entries: { description: "Recent log entries, NEWEST FIRST: { level, message, timestamp, functionPath?, fields? }.", type: "array" }, + total: { description: "Entries available before `limit`/`level` narrowed them.", type: "number" }, + }, + required: ["entries", "total"], + type: "object", +}; + +const ISSUES_OUTPUT_SCHEMA: ToolInputSchema = { + properties: { + issues: { description: "Grouped error Issues, newest first: { hash, title, count, status, functionPath, lastSeen, … }.", type: "array" }, + }, + required: ["issues"], + type: "object", +}; + +const ADVISORIES_OUTPUT_SCHEMA: ToolInputSchema = { + properties: { + advisories: { description: "Schema/query advisories: { id, level, title, detail, … }.", type: "array" }, + total: { description: "Advisories available before `limit` narrowed them.", type: "number" }, + }, + required: ["advisories", "total"], + type: "object", +}; + +const INSIGHTS_OUTPUT_SCHEMA: ToolInputSchema = { + properties: { + buckets: { description: "Combined throughput/latency series across the range.", type: "array" }, + capped: { description: "True when the deployment's tracked-statement cap was reached, so coverage is partial.", type: "boolean" }, + entries: { description: "Per-statement activity in the range, hottest first.", type: "array" }, + total: { description: "Statements available before `limit` narrowed them.", type: "number" }, + trackedStatements: { description: "Distinct statements the deployment is tracking.", type: "number" }, + }, + required: ["entries", "buckets"], + type: "object", +}; + +const MIGRATION_STATUS_OUTPUT_SCHEMA: ToolInputSchema = { + properties: { + migrations: { description: "Every declared migration with its applied/pending state.", type: "array" }, + }, + required: ["migrations"], + type: "object", +}; + +/** + * The observability tools. Descriptions say WHEN to call the tool, not just + * what it returns — an agent picking between five similar reads needs the + * trigger, and it pays for these strings on every turn. + */ +const OBSERVABILITY_TOOL_DEFINITIONS: ReadonlyArray = [ + { + annotations: { ...READ_ONLY_ANNOTATIONS, title: "Read recent logs" }, + description: + "Read the deployment's recent log entries (newest first) after running a function, to see what it printed and where it failed. In-memory and per-shard: resets when the shard hibernates.", + inputSchema: LOGS_INPUT_SCHEMA, + name: "lunora_get_logs", + outputSchema: LOGS_OUTPUT_SCHEMA, + }, + { + annotations: { ...READ_ONLY_ANNOTATIONS, title: "List grouped error Issues" }, + description: + "List errors grouped into Issues by fingerprint, with occurrence counts and triage status — the first call when asking what is currently broken, rather than reading raw logs.", + inputSchema: ISSUES_INPUT_SCHEMA, + name: "lunora_get_issues", + outputSchema: ISSUES_OUTPUT_SCHEMA, + }, + { + annotations: { ...READ_ONLY_ANNOTATIONS, title: "List schema and query advisories" }, + description: "List the deployment's schema/query advisories (missing indexes, unsafe policies, and similar lints) before or after changing the schema.", + inputSchema: ADVISORIES_INPUT_SCHEMA, + name: "lunora_get_advisories", + outputSchema: ADVISORIES_OUTPUT_SCHEMA, + }, + { + annotations: { ...READ_ONLY_ANNOTATIONS, title: "Read query insights" }, + description: "Read per-statement execution counts and latency over a recent time window, to find which query is slow or hot before optimizing one.", + inputSchema: INSIGHTS_INPUT_SCHEMA, + name: "lunora_get_query_insights", + outputSchema: INSIGHTS_OUTPUT_SCHEMA, + }, + { + annotations: { ...READ_ONLY_ANNOTATIONS, title: "Read migration status" }, + description: "Read which migrations have been applied and which are pending, to check whether a schema change has actually landed on the deployment.", + inputSchema: MIGRATION_STATUS_INPUT_SCHEMA, + name: "lunora_get_migration_status", + outputSchema: MIGRATION_STATUS_OUTPUT_SCHEMA, + }, +]; + +/** Names of the observability tools — used to gate them out of a server with no admin token. */ +const OBSERVABILITY_TOOL_NAMES: ReadonlySet = new Set(OBSERVABILITY_TOOL_DEFINITIONS.map((tool) => tool.name)); + +/** + * Call one admin RPC. `client.query` is the right seam and not a workaround: + * the `__lunora_admin__:*` paths travel the same `POST /_lunora/rpc` envelope + * as an ordinary function (the shard intercepts them before user dispatch), so + * the bearer, the error envelope, and the response decode are all the client's + * existing behaviour. + */ +const adminRead = async (client: LunoraClient, op: string, args: Record, shardKey: string | undefined): Promise => { + const reference = { __lunoraRef: op } as FunctionReference; + + return client.query(reference, args, { ...(shardKey === undefined ? {} : { shardKey }) }); +}; + +/** The array under `key` in an admin result, or `[]` when the deployment returned something else. */ +const rowsOf = (result: unknown, key: string): unknown[] => { + const value = (result as Record | null | undefined)?.[key]; + + return Array.isArray(value) ? value : []; +}; + +/** `getLogs` returns the whole ring; only `level` is read here, so the row stays open. */ +interface LogRow { + level?: unknown; +} + +/** + * Dispatch an observability tool. Throws on an unknown name or a failed RPC — + * `callTool` owns the try/catch that turns either into an `isError` result, so + * every tool family reports failures the same way. + */ +const callObservabilityTool = async (client: LunoraClient, name: string, input: Record): Promise => { + const shardKey = readText(input.shardKey); + const limit = readLimit(input.limit); + + switch (name) { + case "lunora_get_advisories": { + const result = await adminRead(client, ADMIN_FUNCTIONS.getAdvisories, {}, shardKey); + const advisories = rowsOf(result, "advisories"); + + return okStructured({ advisories: advisories.slice(0, limit), total: advisories.length }); + } + case "lunora_get_issues": { + const status = readEnum(input.status, ISSUE_STATUSES); + const functionPathPrefix = readText(input.functionPathPrefix); + // The RPC applies `limit`/`status`/`functionPathPrefix` itself (and + // clamps the limit), so grouping happens over the right row set + // rather than over a page this tool already truncated. + const result = await adminRead( + client, + ADMIN_FUNCTIONS.getIssues, + { + limit, + ...(status === undefined ? {} : { status }), + ...(functionPathPrefix === undefined ? {} : { functionPathPrefix }), + }, + shardKey, + ); + + return okStructured({ issues: rowsOf(result, "issues") }); + } + case "lunora_get_logs": { + const level = readEnum(input.level, LOG_LEVELS); + // `getLogs` takes no arguments and returns the entire in-memory ring + // (up to 500 entries), so the narrowing happens here. Entries arrive + // newest-first, so slicing keeps the most recent. + const result = await adminRead(client, ADMIN_FUNCTIONS.getLogs, {}, shardKey); + const entries = rowsOf(result, "entries").filter((entry) => level === undefined || (entry as LogRow).level === level); + + return okStructured({ entries: entries.slice(0, limit), total: entries.length }); + } + case "lunora_get_migration_status": { + const result = await adminRead(client, ADMIN_FUNCTIONS.migrationStatus, {}, shardKey); + + // No limit: the list is one row per declared migration, and dropping + // its tail would hide exactly the pending one the caller is asking about. + return okStructured({ migrations: rowsOf(result, "migrations") }); + } + case "lunora_get_query_insights": { + const range = readEnum(input.range, INSIGHT_RANGES); + const result = await adminRead(client, ADMIN_FUNCTIONS.getQueryInsights, { ...(range === undefined ? {} : { range }) }, shardKey); + const entries = rowsOf(result, "entries"); + const { buckets, capped, trackedStatements } = (result ?? {}) as { buckets?: unknown; capped?: unknown; trackedStatements?: unknown }; + + return okStructured({ + buckets: Array.isArray(buckets) ? buckets : [], + capped: capped === true, + entries: entries.slice(0, limit), + total: entries.length, + trackedStatements: typeof trackedStatements === "number" ? trackedStatements : entries.length, + }); + } + default: { + throw new LunoraError("INTERNAL", `unknown observability tool: ${name}`); + } + } +}; + +export { callObservabilityTool, DEFAULT_LIMIT, MAX_LIMIT, OBSERVABILITY_TOOL_DEFINITIONS, OBSERVABILITY_TOOL_NAMES }; diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index ffd799e322..2034a632af 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -103,6 +103,10 @@ interface LunoraMcpServerOptions { * therefore NOT enforced by the token's scope; it is enforced in-process via * `allowWrites: false` (the default), which omits the write tools from the * advertised list and refuses them at dispatch. + * + * Its presence is also what gates the observability tools (logs, Issues, + * advisories, query insights, migration status): without a token they are + * omitted from `ListTools` and refused at dispatch. */ token?: string; /** Base URL of the deployed Lunora Worker. Required unless `client` is given. */ @@ -142,10 +146,15 @@ const createLunoraMcpServer = (options: LunoraMcpServerOptions): Server => { const allowWrites = options.allowWrites ?? false; const allowAgents = options.allowAgents ?? false; const agents = options.agents ?? []; + // The observability tools' gate. Read off `options.token` rather than the + // client, because a caller that injects a pre-built `client` has not told + // this server what that client can reach — and the fail-closed reading of + // "unknown" is "no privileged tools". + const hasAdminToken = typeof options.token === "string" && options.token.length > 0; const server = new Server(SERVER_INFO, { capabilities: { tools: {} } }); server.setRequestHandler(ListToolsRequestSchema, () => { - return { tools: [...toolDefinitions(allowWrites), ...agentToolDefinitions(agents, allowAgents)] }; + return { tools: [...toolDefinitions(allowWrites, hasAdminToken), ...agentToolDefinitions(agents, allowAgents)] }; }); server.setRequestHandler(CallToolRequestSchema, async (request): Promise => { @@ -161,7 +170,7 @@ const createLunoraMcpServer = (options: LunoraMcpServerOptions): Server => { ...(options.agentMaxWaitMs === undefined ? {} : { maxWaitMs: options.agentMaxWaitMs }), ...(options.agentPollIntervalMs === undefined ? {} : { pollIntervalMs: options.agentPollIntervalMs }), }) - : await callTool(client, name, input, allowWrites); + : await callTool(client, name, input, allowWrites, hasAdminToken); return result as CallToolResult; }); diff --git a/packages/mcp/src/tool-result.ts b/packages/mcp/src/tool-result.ts new file mode 100644 index 0000000000..675bf6cc0a --- /dev/null +++ b/packages/mcp/src/tool-result.ts @@ -0,0 +1,98 @@ +/** + * Building a `ToolResult` from a value that came back over the wire. + * + * Its own module (rather than `./tools`) because two tool families need the + * same JSON-safety guarantee: the deployment tools (`./tools`) and the + * observability tools (`./observability-tools`). Keeping it here also keeps + * `./observability-tools` off `./tools`, which would be an import cycle. + */ +import type { ToolResult } from "./tool-types"; + +/** + * Base64-encode bytes for the model-visible JSON, chunking to stay under + * `String.fromCharCode`'s argument-count ceiling on large buffers (mirrors the + * wire codec's own encoder). + */ +const bytesToBase64 = (bytes: Uint8Array): string => { + let binary = ""; + const chunk = 0x80_00; + + for (let index = 0; index < bytes.length; index += chunk) { + // eslint-disable-next-line unicorn/prefer-code-point -- byte values 0-255 -> latin1; fromCharCode is correct and faster here + binary += String.fromCharCode(...bytes.subarray(index, index + chunk)); + } + + return btoa(binary); +}; + +/** + * `JSON.stringify` replacer for a decoded RPC result. `LunoraClient` decodes + * EVERY response (`rpc()` ends in `decodeWire(body.result)`), which revives + * `v.int64()` leaves as real `bigint` and `v.bytes()`/typed-array columns as + * `ArrayBuffer`/typed arrays — and that holds for admin reads too, which are + * served WITHOUT a matching `encodeWire` but ride the same decode on the way + * back. Raw `JSON.stringify` THROWS on a bigint (turning a successful call into + * a tool error) and serializes an `ArrayBuffer` to `{}` / a `Uint8Array` to an + * index-keyed object (silent corruption). Map every bigint → its decimal string + * and every bytes leaf → base64 so the model sees a faithful value instead. + */ +const jsonResultReplacer = (_key: string, value: unknown): unknown => { + if (typeof value === "bigint") { + return value.toString(); + } + + if (value instanceof ArrayBuffer) { + return bytesToBase64(new Uint8Array(value)); + } + + if (ArrayBuffer.isView(value)) { + const view = value; + + return bytesToBase64(new Uint8Array(view.buffer, view.byteOffset, view.byteLength)); + } + + return value; +}; + +/** + * The JSON-safe projection of a decoded result — the same mapping + * {@link jsonResultReplacer} applies to the text block, materialized as a + * value. `structuredContent` is serialized by the transport, so an unconverted + * bigint there would throw at the protocol layer instead of at the tool, taking + * the whole response down rather than one field. + * + * Takes an object (never a bare value), which is both what MCP requires of + * `structuredContent` and what makes `JSON.stringify` total here. + */ +const toJsonSafe = (value: Record): Record => + JSON.parse(JSON.stringify(value, jsonResultReplacer)) as Record; + +/** A text-only success result: the value as pretty JSON in one text block. */ +const ok = (value: unknown): ToolResult => { + // A void-returning mutation/action resolves to `undefined`, and + // `JSON.stringify(undefined)` yields the JS value `undefined` (not a + // string), which violates both `ToolResult.content[].text: string` and the + // MCP `TextContent` contract. Emit the JSON `null` literal in that case. + const text = value === undefined ? "null" : JSON.stringify(value, jsonResultReplacer, 2); + + return { content: [{ text, type: "text" }] }; +}; + +/** + * A success result carrying BOTH the text block and `structuredContent`. + * + * The text block is not redundant: `structuredContent` arrived in MCP revision + * `2025-06-18`, and a client that negotiates an older revision (the SDK still + * supports `2025-03-26` and `2024-11-05`) simply ignores the field. Emitting + * both means one result shape works on every revision. + */ +const okStructured = (value: Record): ToolResult => { + return { ...ok(value), structuredContent: toJsonSafe(value) }; +}; + +/** An error result: the message as tool output, per the MCP convention (never a rejection). */ +const errorResult = (message: string): ToolResult => { + return { content: [{ text: message, type: "text" }], isError: true }; +}; + +export { bytesToBase64, errorResult, jsonResultReplacer, ok, okStructured, toJsonSafe }; diff --git a/packages/mcp/src/tool-types.ts b/packages/mcp/src/tool-types.ts index cec19c80e3..f5a7068670 100644 --- a/packages/mcp/src/tool-types.ts +++ b/packages/mcp/src/tool-types.ts @@ -43,12 +43,28 @@ interface ToolDefinition { description: string; inputSchema: ToolInputSchema; name: string; + + /** + * JSON Schema for the tool's `structuredContent`. Optional: a tool that + * emits only a text block declares none, and a client that negotiated an + * MCP revision older than `2025-06-18` ignores it either way. + */ + outputSchema?: ToolInputSchema; } /** The MCP `CallToolResult` shape this package's tools return. */ interface ToolResult { content: { text: string; type: "text" }[]; isError?: boolean; + + /** + * The machine-readable result, mirroring the text block. Present only for + * tools that declare an `outputSchema`; per the MCP spec it must be a JSON + * OBJECT (never a bare array) and must survive `JSON.stringify` — the + * transport serializes it, so an un-mapped `bigint` here fails the whole + * response rather than one field. + */ + structuredContent?: Record; } export type { ToolAnnotations, ToolDefinition, ToolInputSchema, ToolResult }; diff --git a/packages/mcp/src/tools.ts b/packages/mcp/src/tools.ts index 202731f987..2da6f4dafc 100644 --- a/packages/mcp/src/tools.ts +++ b/packages/mcp/src/tools.ts @@ -1,6 +1,8 @@ import type { FunctionDescriptor, FunctionReference, LunoraClient } from "@lunora/client"; import { LunoraError } from "@lunora/errors"; +import { callObservabilityTool, OBSERVABILITY_TOOL_DEFINITIONS, OBSERVABILITY_TOOL_NAMES } from "./observability-tools"; +import { errorResult, ok } from "./tool-result"; import type { ToolDefinition, ToolInputSchema, ToolResult } from "./tool-types"; /** @@ -92,17 +94,26 @@ const WRITE_TOOL_DEFINITIONS: ReadonlyArray = [ const WRITE_TOOL_NAMES: ReadonlySet = new Set(WRITE_TOOL_DEFINITIONS.map((tool) => tool.name)); /** - * The tools this server advertises. When `allowWrites` is false (the default), - * only the read-only surface is exposed — the mutation/action tools are omitted - * from `ListTools` entirely, so an AI agent can't invoke a write it can't see. + * The tools this server advertises, in three tiers: + * + * - the read-only surface, always exposed; + * - the observability surface, exposed only when an admin token resolved — + * read-only, but it surfaces production logs and grouped errors, so an + * unauthenticated server must not even advertise that it exists; + * - the write surface, exposed only when `allowWrites` is set. + * + * Both gates OMIT rather than refuse: an AI agent can't invoke what it can't + * see. Dispatch re-checks both in {@link callTool}, so the guarantee does not + * depend on a client honouring the advertised list. */ -const toolDefinitions = (allowWrites: boolean): ReadonlyArray => +const toolDefinitions = (allowWrites: boolean, hasAdminToken = false): ReadonlyArray => // Fail closed: only the boolean `true` opts in. These are exported helpers, so // an env-plumbed/JS caller could pass a truthy string like `"false"`/`"0"` — // the explicit `=== true` guards that despite the declared `boolean` type. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-boolean-literal-compare -- intentional runtime guard at an exported API boundary against non-boolean callers - allowWrites === true ? [...READ_ONLY_TOOL_DEFINITIONS, ...WRITE_TOOL_DEFINITIONS] : READ_ONLY_TOOL_DEFINITIONS; + /* eslint-disable @typescript-eslint/no-unnecessary-boolean-literal-compare -- intentional runtime guard at an exported API boundary against non-boolean callers */ + [...READ_ONLY_TOOL_DEFINITIONS, ...(hasAdminToken === true ? OBSERVABILITY_TOOL_DEFINITIONS : []), ...(allowWrites === true ? WRITE_TOOL_DEFINITIONS : [])]; +/* eslint-enable @typescript-eslint/no-unnecessary-boolean-literal-compare */ /** Extract and validate `functionPath` from an MCP `arguments` bag. */ const readFunctionPath = (input: Record): string => { const { functionPath } = input; @@ -175,60 +186,6 @@ const reference = (functionPath: string): FunctionReference => { return { __lunoraRef: functionPath }; }; -/** - * Base64-encode bytes for the model-visible JSON, chunking to stay under - * `String.fromCharCode`'s argument-count ceiling on large buffers (mirrors the - * wire codec's own encoder). - */ -const bytesToBase64 = (bytes: Uint8Array): string => { - let binary = ""; - const chunk = 0x80_00; - - for (let index = 0; index < bytes.length; index += chunk) { - // eslint-disable-next-line unicorn/prefer-code-point -- byte values 0-255 -> latin1; fromCharCode is correct and faster here - binary += String.fromCharCode(...bytes.subarray(index, index + chunk)); - } - - return btoa(binary); -}; - -/** - * `JSON.stringify` replacer for a decoded RPC result. `client.query`/`mutation`/ - * `action` return `decodeWire(...)`, which revives `v.int64()` leaves as real - * `bigint` and `v.bytes()`/typed-array columns as `ArrayBuffer`/typed arrays. - * Raw `JSON.stringify` THROWS on a bigint (turning a successful call into a tool - * error) and serializes an `ArrayBuffer` to `{}` / a `Uint8Array` to an - * index-keyed object (silent corruption). Map every bigint → its decimal string - * and every bytes leaf → base64 so the model sees a faithful value instead. - */ -const jsonResultReplacer = (_key: string, value: unknown): unknown => { - if (typeof value === "bigint") { - return value.toString(); - } - - if (value instanceof ArrayBuffer) { - return bytesToBase64(new Uint8Array(value)); - } - - if (ArrayBuffer.isView(value)) { - const view = value; - - return bytesToBase64(new Uint8Array(view.buffer, view.byteOffset, view.byteLength)); - } - - return value; -}; - -const ok = (value: unknown): ToolResult => { - // A void-returning mutation/action resolves to `undefined`, and - // `JSON.stringify(undefined)` yields the JS value `undefined` (not a - // string), which violates both `ToolResult.content[].text: string` and the - // MCP `TextContent` contract. Emit the JSON `null` literal in that case. - const text = value === undefined ? "null" : JSON.stringify(value, jsonResultReplacer, 2); - - return { content: [{ text, type: "text" }] }; -}; - /** * The deployment's public-function registry is static per deploy, but every run * tool (via {@link assertRunnable}) and `lunora_get_function_schema` needs it — @@ -296,21 +253,34 @@ const assertRunnable = async (client: LunoraClient, functionPath: string, expect * returned as `isError` results (rather than rejections) so the calling model * sees the failure as tool output, per the MCP convention. * - * `allowWrites` gates the mutation/action tools: when false (the default) a call - * to a write tool is refused even if the client somehow names it, so the - * read-only guarantee holds at dispatch, not just in the advertised tool list. + * `allowWrites` gates the mutation/action tools and `hasAdminToken` gates the + * observability tools: when either is false a call to the gated tool is refused + * even if the client somehow names it, so both guarantees hold at dispatch, not + * just in the advertised tool list. */ -const callTool = async (client: LunoraClient, name: string, input: Record, allowWrites = false): Promise => { +const callTool = async ( + client: LunoraClient, + name: string, + input: Record, + allowWrites = false, + hasAdminToken = false, +): Promise => { try { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-boolean-literal-compare -- intentional runtime guard at an exported API boundary against non-boolean callers + /* eslint-disable @typescript-eslint/no-unnecessary-boolean-literal-compare -- intentional runtime guard at an exported API boundary against non-boolean callers */ if (allowWrites !== true && WRITE_TOOL_NAMES.has(name)) { - return { - content: [ - { text: `tool "${name}" is disabled: this MCP server is read-only. Enable writes with the LUNORA_MCP_ALLOW_WRITES env var.`, type: "text" }, - ], - isError: true, - }; + return errorResult(`tool "${name}" is disabled: this MCP server is read-only. Enable writes with the LUNORA_MCP_ALLOW_WRITES env var.`); + } + + if (OBSERVABILITY_TOOL_NAMES.has(name)) { + if (hasAdminToken !== true) { + return errorResult( + `tool "${name}" is unavailable: it reads the deployment's logs and errors, which needs an admin token. Set LUNORA_ADMIN_TOKEN (or pass --token) and reconnect.`, + ); + } + + return await callObservabilityTool(client, name, input); } + /* eslint-enable @typescript-eslint/no-unnecessary-boolean-literal-compare */ switch (name) { case "lunora_get_function_schema": { @@ -319,7 +289,7 @@ const callTool = async (client: LunoraClient, name: string, input: Record function_.path === functionPath); if (descriptor === undefined) { - return { content: [{ text: `function not found: ${functionPath}`, type: "text" }], isError: true }; + return errorResult(`function not found: ${functionPath}`); } return ok({ args: descriptor.args ?? [], kind: descriptor.kind, path: descriptor.path }); @@ -352,16 +322,17 @@ const callTool = async (client: LunoraClient, name: string, input: Record/bundle-meta.json` + already carries esbuild's `bytesInOutput`. + +### Considered and not filed + +Verified already shipped, and in most cases further along than the comparison +set: snapshot import/export incl. blobs (plans 304/305), `lunora seed`, `env +diff`/`push`/`doctor`, `logs --ndjson` + `--durable` archive reads, preview + +temporary deploys with `--format json`, MCP fail-closed write gating with tool +annotations, `.lunora/dev.json` agent status + background dev, `.lunora/project.json` +linking, per-function argument validation at the builder (so every entrypoint +inherits it), Cloudflare Access identity, log/metric retention, cron metadata in +codegen output, and the capability matrix's `platform_unsupported_feature` +diagnostic. + +**Rejected:** _automatic shard scale-out_ conflicts with `.shardBy` being an +explicit, reviewable topology decision (cross-shard work is plan 168). _A +browser DevTools extension_ duplicates the Studio. _A public-env-var count gate_ +is process for its own sake. _Running the project's test suite inside `lunora +verify`_ is YAGNI — an agent can run `pnpm test` itself; the half with real +value (live function-spec parity against the deployed worker) is a candidate for +a later wave. + ## Notes for executors (carried from prior waves) - `dist/` is gitignored and built on demand. Build deps first: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e707186202..a57e299117 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3442,6 +3442,9 @@ importers: '@lunora/errors': specifier: workspace:* version: link:../errors + '@lunora/shard-engine': + specifier: workspace:* + version: link:../shard-engine '@modelcontextprotocol/sdk': specifier: catalog:mcp version: 1.29.0(supports-color@10.2.2)(zod@4.4.3) diff --git a/scripts/check-worker-size.js b/scripts/check-worker-size.js new file mode 100644 index 0000000000..d8e82c2226 --- /dev/null +++ b/scripts/check-worker-size.js @@ -0,0 +1,191 @@ +#!/usr/bin/env node +/** + * Weighs the Worker a new user actually deploys, and fails when it grows past a + * committed ceiling. + * + * Nothing else in this repo measures bytes. `dist:check` audits whether a built + * `dist/` is production-clean, and per-package sizes answer the wrong question + * entirely: every entrypoint is a re-export shim (`packages/runtime/dist/index.mjs` + * is 2 KiB while its code sits in `dist/packem_shared/`). Only the bundled Worker + * is a real number, and the first person to learn this framework's floor should + * not be a user whose deploy Cloudflare rejected for a dependency added weeks ago. + * + * The reference app is `templates/standalone` — the smallest starter, and what a + * new project is. Deliberately NOT `apps/playground`, which accumulates feature + * demos (auth-ui, db, queue, workflow, studio…) and would track demo churn rather + * than the framework's own weight. + * + * The measurement comes from `lunora build --format json`, so the gate and the + * number a user sees are produced by the same code path. + * + * Usage: + * pnpm run worker-size:check # fail when the bundle exceeds the ceiling + * pnpm run worker-size:update # re-baseline after an intentional increase + * + * PREREQUISITE: `pnpm run build:packages:prod`. The reference app resolves the + * workspace `dist/` directories, and users install the PRODUCTION build — a plain + * `build:packages` measures a development bundle roughly 25% heavier, which is a + * number nobody deploys. + * + * Runs as its own CI job, NOT from `postinstall`: a failing postinstall gate turns + * every job red in its setup step, and the cause is invisible in the job that + * reports it. + */ +import { execFileSync } from "node:child_process"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = join(dirname(fileURLToPath(import.meta.url)), ".."); +const fixturePath = join(rootDir, "worker-size.json"); +const update = process.argv.includes("--update"); + +const fail = (message) => { + process.stderr.write(`${message}\n`); + process.exit(1); +}; + +const kib = (bytes) => `${(bytes / 1024).toFixed(1)} KiB`; + +/** + * Cloudflare's pre-compression Worker script limit — 64 MB, the same on Free and + * Paid, alongside the per-plan gzip limit (3 MB / 10 MB). + * https://developers.cloudflare.com/workers/platform/limits/ + */ +const RAW_LIMIT_BYTES = 64 * 1024 * 1024; + +/** + * Materialize the reference template as a project whose dependencies resolve. + * + * A symlink farm rather than an install: every `@lunora/*` dependency is a + * workspace package that already has its own `node_modules`, so linking the + * package directories is enough for esbuild to resolve the whole graph — and it + * keeps the gate offline and quick. `pnpm install` here would try to fetch + * `lunorash@^0.0.0` from the registry instead. + */ +const materialize = (appDirectory) => { + cpSync(join(rootDir, "templates", "standalone"), appDirectory, { recursive: true }); + + for (const file of ["package.json", "wrangler.jsonc"]) { + const path = join(appDirectory, file); + + writeFileSync(path, readFileSync(path, "utf8").replaceAll("{{name}}", "lunora-worker-size-reference"), "utf8"); + } + + // The CLI runs wrangler through the project's package manager. A pnpm-shaped + // project makes `pnpm exec` verify the (absent) lockfile first and abort; + // `npx --` runs the linked binary directly, so the reference app declares + // itself npm-shaped with an empty lockfile. + writeFileSync(join(appDirectory, "package-lock.json"), '{ "lockfileVersion": 3 }\n', "utf8"); + + const modules = join(appDirectory, "node_modules"); + + mkdirSync(join(modules, "@lunora"), { recursive: true }); + + for (const directory of readdirSync(join(rootDir, "packages"))) { + const source = join(rootDir, "packages", directory); + const manifest = join(source, "package.json"); + + if (existsSync(manifest)) { + symlinkSync(source, join(modules, JSON.parse(readFileSync(manifest, "utf8")).name)); + } + } + + // wrangler and the Workers types come from a workspace package that already + // depends on them, so the gate needs no dependencies of its own. + const host = join(rootDir, "packages", "runtime", "node_modules"); + + symlinkSync(join(host, "wrangler"), join(modules, "wrangler")); + symlinkSync(join(host, "@cloudflare"), join(modules, "@cloudflare")); + mkdirSync(join(modules, ".bin"), { recursive: true }); + symlinkSync(join(host, ".bin", "wrangler"), join(modules, ".bin", "wrangler")); +}; + +/** Build the reference app and return `lunora build`'s `bundle` measurement. */ +const measure = (appDirectory) => { + const cli = join(rootDir, "packages", "cli", "dist", "bin.mjs"); + + if (!existsSync(cli)) { + fail(`check-worker-size: ${cli} is missing — run \`pnpm run build:packages:prod\` first.`); + } + + let stdout; + + try { + stdout = execFileSync(process.execPath, [cli, "build", "--format", "json", "--out-dir", "out"], { + cwd: appDirectory, + encoding: "utf8", + env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, + maxBuffer: 64 * 1024 * 1024, + stdio: ["ignore", "pipe", "inherit"], + }); + } catch { + fail("check-worker-size: `lunora build` failed on the reference template (its output is above)."); + } + + const result = JSON.parse(stdout); + + // A measurement of zero must never pass as a healthy result: that is exactly + // what a changed wrangler out-dir layout would look like. + if (!result.bundle || result.bundle.files < 1 || result.bundle.gzipBytes < 1) { + fail("check-worker-size: `lunora build` reported no bundle — the wrangler out-dir layout may have changed."); + } + + return result.bundle; +}; + +const baseline = JSON.parse(readFileSync(fixturePath, "utf8")); +const appDirectory = join(mkdtempSync(join(tmpdir(), "lunora-worker-size-")), "app"); + +let bundle; + +try { + materialize(appDirectory); + bundle = measure(appDirectory); +} finally { + rmSync(join(appDirectory, ".."), { force: true, recursive: true }); +} + +const ceiling = baseline.gzipBytes + baseline.allowanceBytes; + +process.stdout.write( + `worker size (${baseline.template}): ${kib(bundle.rawBytes)} raw, ${kib(bundle.gzipBytes)} gzipped — ` + + `baseline ${kib(baseline.gzipBytes)}, ceiling ${kib(ceiling)}\n`, +); + +if (update) { + const delta = bundle.gzipBytes - baseline.gzipBytes; + + writeFileSync(fixturePath, `${JSON.stringify({ ...baseline, gzipBytes: bundle.gzipBytes, rawBytes: bundle.rawBytes }, undefined, 4)}\n`, "utf8"); + process.stdout.write(`worker-size.json updated: ${delta >= 0 ? "+" : ""}${kib(delta)} gzipped against the previous baseline.\n`); + + process.exit(0); +} + +// Cloudflare enforces two ceilings, not one: the plan's gzip limit AND a 64 MB +// pre-compression limit on both plans. The baseline check below is a regression +// signal measured in gzip, so it cannot see a bundle that compresses extremely +// well — a large generated lookup table is tiny gzipped and enormous raw. That +// bundle would pass the gate and be rejected at upload, so the raw limit gets +// its own absolute check. +if (bundle.rawBytes > RAW_LIMIT_BYTES) { + fail( + `The reference Worker (templates/${baseline.template}) exceeds Cloudflare's pre-compression limit.\n` + + ` raw: ${kib(bundle.rawBytes)}\n` + + ` limit: ${kib(RAW_LIMIT_BYTES)} before compression (both plans)\n` + + `This is an upload-time rejection, not a budget — accepting it with \`worker-size:update\` will not help.`, + ); +} + +if (bundle.gzipBytes > ceiling) { + fail( + `The reference Worker (templates/${baseline.template}) grew past its ceiling.\n` + + ` now: ${kib(bundle.gzipBytes)} gzipped (${kib(bundle.rawBytes)} raw)\n` + + ` baseline: ${kib(baseline.gzipBytes)} gzipped, + ${kib(baseline.allowanceBytes)} allowance = ${kib(ceiling)}\n` + + ` delta: +${kib(bundle.gzipBytes - baseline.gzipBytes)} against the baseline\n` + + `Every Lunora app carries this. Find what arrived (\`lunora analyze\` prints the heaviest modules),\n` + + `and if the growth is intended, accept it with \`pnpm run worker-size:update\` so the increase is\n` + + `visible in review rather than buried.`, + ); +} diff --git a/worker-size.json b/worker-size.json new file mode 100644 index 0000000000..da8ce2078f --- /dev/null +++ b/worker-size.json @@ -0,0 +1,7 @@ +{ + "$comment": "Committed size baseline for the Worker a `templates/standalone` app deploys, measured by scripts/check-worker-size.js against a production build. Cloudflare's Worker script limit is 3 MB gzipped on the Free plan and 10 MB on Paid, so this is a framework-weight regression signal, not a proximity alarm. `pnpm run worker-size:update` re-baselines an intentional increase.", + "template": "standalone", + "gzipBytes": 422840, + "rawBytes": 1725313, + "allowanceBytes": 51200 +}