diff --git a/CLAUDE.md b/CLAUDE.md index 5ad43fb..417990f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,8 +29,12 @@ Scope any script to one package with `-w`, e.g. `npm test -w @meter-mcp/sdk`. There is no ESLint/Prettier config; `npm run lint:packages` lints *package metadata* (publint + are-the-types-wrong), not source style. +`npm run typecheck` is wider than the four packages: it also compiles `examples/meter-sdk` and the CLI scaffolding templates (`packages/cli/templates/tsconfig.json`). Editing an example or a template can break the gate. + `scripts/verify-sdk-packages.mjs` is the strictest part of `verify`: it packs real tarballs, installs them into a clean consumer, and imports them from ESM, CommonJS, TS-ESM, and TS-CJS. Export-map or `.d.cts` regressions surface here, not in `npm test`. +CI (`.github/workflows/ci.yml`) runs `npm run verify` on Node 20, 22, and 24, plus a full-history gitleaks scan. + ## Architecture Four npm workspaces under `packages/`, published together at a **single shared version** (Changesets `fixed` group; `scripts/verify-sdk-release.mjs` fails the release if versions diverge): @@ -38,7 +42,9 @@ Four npm workspaces under `packages/`, published together at a **single shared v - `@meter-mcp/sdk` — zero-dependency HTTP client (`MeterPublicApiClient`), AI-cost math, webhook signature verification. Everything else depends on it. - `@meter-mcp/mcp` — `paidTool` / `registerPaidTool`, which wrap an MCP tool handler in billing and translate a `402`-style error body into an MCP error result. - `@meter-mcp/adapters` — provider usage extractors (`aiUsageFromOpenAI` / `aiUsageFromAnthropic`) and Web-`Request`/`Response` handlers for buyer-portal and operator-console sessions, plus an Express bridge. -- `@meter-mcp/cli` — the `meter` bin for provider developers: `login` / `init` (scaffolds an embedded-metering MCP server from `packages/cli/templates`, which ship in the tarball) / resource CRUD / `usage` + `events tail` / `webhooks` + `listen` (poll-mode webhook forwarding with client-side HMAC signing) / `call`. Commands are pure `run*(ctx, …)` functions in `src/commands/`; `src/cli.ts` only wires commander. Three wiring bugs unit tests cannot catch live in commander/bundle land — bin symlink vs `import.meta.url` realpath, global-vs-subcommand option shadowing, bundled-`dist` resource paths — which is why `test/init.test.ts` spawns the **built** `dist/cli.js` end-to-end; keep that test alive. +- `@meter-mcp/cli` — the `meter` bin for provider developers: `login` / `init` / `oauth-proxy` / resource CRUD / `usage` + `events tail` / `webhooks` + `listen` (poll-mode webhook forwarding with client-side HMAC signing) / `call`. Commands are pure `run*(ctx, …)` functions in `src/commands/`; `src/cli.ts` only wires commander. Three wiring bugs unit tests cannot catch live in commander/bundle land — bin symlink vs `import.meta.url` realpath, global-vs-subcommand option shadowing, bundled-`dist` resource paths — which is why `test/init.test.ts` spawns the **built** `dist/cli.js` end-to-end; keep that test alive. + +`packages/cli/templates/` holds three scaffolding trees (`init` Node, `init --target cloudflare`, `oauth-proxy`). Nothing under `templates/cloudflare-oauth/` is compiled or run by `npm run verify` — see [docs/CLI_TEMPLATES.md](./docs/CLI_TEMPLATES.md) before editing any of them. ### The billing lifecycle @@ -59,7 +65,7 @@ The core of the SDK is the three-phase call in `packages/sdk/src/client.ts`: ## Conventions -- ESM source (`.ts`, NodeNext), `"type": "module"`, relative imports carry the `.js` extension. +- ESM source (`.ts`, NodeNext), `"type": "module"`, relative imports carry the `.js` extension. The one exception is `templates/cloudflare-oauth/`, which is bundler-resolved and extensionless on purpose. - Node >= 20; the SDK relies on global `fetch`, `crypto.randomUUID`, and `crypto.subtle` rather than any runtime dependency. Keep `@meter-mcp/sdk` dependency-free. - New public API needs: an entry in `docs/SDK_API.md`, a test, and a changeset. -- Releases are tag-driven (`sdk-v`) and publish via GitHub OIDC trusted publishing — see `docs/SDK_RELEASE.md`. Never overwrite a published version. +- Releases are tag-driven (`sdk-v`) and publish via GitHub OIDC trusted publishing — see `docs/SDK_RELEASE.md`. Never overwrite a published version. `scripts/verify-sdk-release.mjs` runs in `sdk-publish.yml` (not in `verify`) and hard-fails unless all four versions match, the tag is exactly `sdk-v`, and every manifest carries the `repository.url` and `publishConfig` that trusted publishing is bound to — so renaming the repo or touching `publishConfig` breaks publishing, not CI. diff --git a/docs/CLI_TEMPLATES.md b/docs/CLI_TEMPLATES.md new file mode 100644 index 0000000..c56bdd3 --- /dev/null +++ b/docs/CLI_TEMPLATES.md @@ -0,0 +1,64 @@ +# CLI scaffolding templates + +Maintainer notes for `packages/cli/templates/`, the source trees `meter init` and +`meter oauth-proxy` copy into a user's new project. For the user-facing command +reference see the [CLI README](../packages/cli/README.md). + +## Layout + +`packages/cli/templates/` ships inside the published tarball (it is listed in the +CLI's `files` array) and holds three independent trees: + +| Tree | Scaffolded by | Runtime | +| --- | --- | --- | +| `src/` + root `*.tmpl` | `meter init` (default) | Node.js | +| `cloudflare/` | `meter init --target cloudflare` | Workers | +| `cloudflare-oauth/` | `meter oauth-proxy` | Workers | + +## `oauth-proxy` is not an `init` variant + +`meter init` creates a service and runs onboarding. `meter oauth-proxy` does +neither: it scaffolds a generic Workers OAuth front for an MCP endpoint the +provider already runs, so OAuth-only MCP clients can connect to it. + +The generated Worker source is target-agnostic and is copied verbatim. The target +is supplied at deploy time through Wrangler vars, which the CLI writes into +`wrangler.jsonc`: + +```text +BACKEND_BASE_URL the existing service's origin +MCP_PATH the MCP route on that origin (default /api/mcp) +BUYER_HEADER header carrying the buyer token (default x-meter-buyer-token) +SERVICE_NAME display name on the consent screen +``` + +`test/oauth-proxy.test.ts` asserts the target never leaks into the copied source, +which is what keeps that separation honest. + +## Rendering + +Scaffolding is `__TOKEN__` string replacement over the `.tmpl` files, not a +template engine. Files outside `*.tmpl` are copied byte-for-byte. + +Both `runInit` and `runOAuthProxy` locate the templates with a `packageRoot()` +walk-up to the nearest `package.json`. That is what makes the path resolve +identically under `tsx`, under the tsup-bundled `dist/cli.js`, and from an +installed `node_modules/@meter-mcp/cli`. Do not replace it with a path relative +to `import.meta.url`. + +## What the gate does and does not cover + +`npm run typecheck:templates` compiles the Node and `cloudflare/` trees through +`packages/cli/templates/tsconfig.json`. + +`cloudflare-oauth/` is deliberately outside that tsconfig. It needs +`@cloudflare/workers-oauth-provider`, `@cloudflare/workers-types`, and vitest, +none of which this repository installs, and it uses bundler-style extensionless +imports that the repo's NodeNext resolution would reject. + +The practical consequence: **nothing in `cloudflare-oauth/src/` is compiled or +executed by `npm run verify`.** Its `proxy.test.ts` does not run here either. The +only coverage is the scaffolding assertions in `packages/cli/test/oauth-proxy.test.ts`, +which check that files are emitted and tokens expanded, not that the code is +sound. After editing those files, typecheck them from a generated project against +its own `tsconfig.json`. diff --git a/docs/SDK_RELEASE.md b/docs/SDK_RELEASE.md index a89f95d..e61188b 100644 --- a/docs/SDK_RELEASE.md +++ b/docs/SDK_RELEASE.md @@ -1,23 +1,31 @@ # SDK release runbook -Meter publishes `@meter-mcp/sdk`, `@meter-mcp/mcp`, and `@meter-mcp/adapters` together from -the public `masterleopold/meter-sdk` repository. Meter's service implementation -remains in a separate private repository. +Meter publishes `@meter-mcp/sdk`, `@meter-mcp/mcp`, `@meter-mcp/adapters`, and +`@meter-mcp/cli` together from the public `VoxTechnologies/meter-sdk` repository. +Meter's service implementation remains in a separate private repository. + +That repository slug is not cosmetic: `scripts/verify-sdk-release.mjs` +asserts that every manifest's `repository.url` is +`git+https://github.com/VoxTechnologies/meter-sdk.git`, and npm binds trusted +publishing to that same slug. Renaming or forking the repository breaks +publishing even though CI stays green. ## One-time npm setup 1. Confirm `gitleaks git .` and the GitHub secret-history job complete with zero findings. -2. Sign in to npm with an account that can publish the `@meter` scope. +2. Sign in to npm with an account that can publish the `@meter-mcp` scope. 3. Confirm `npm whoami` and `npm access ls-packages` succeed. 4. Bootstrap each package at `0.1.0` using `npm publish --access public` from its package directory. 5. Configure a trusted GitHub publisher for each package: - - repository: `masterleopold/meter-sdk` + - repository: `VoxTechnologies/meter-sdk` - workflow: `sdk-publish.yml` - environment: `npm` - allowed action: `npm publish` 6. Require approval for the GitHub `npm` environment and disallow token-based publishing on npm. +7. Keep the repository public. The publish workflow's preflight job fails on a + private repository, because npm provenance requires public source. -Bootstrap commands, after creating or joining the `@meter` npm organization: +Bootstrap commands, after creating or joining the `@meter-mcp` npm organization: ```bash npm login @@ -26,16 +34,18 @@ npm run verify npm publish --workspace @meter-mcp/sdk --access public npm publish --workspace @meter-mcp/mcp --access public npm publish --workspace @meter-mcp/adapters --access public +npm publish --workspace @meter-mcp/cli --access public ``` -Then install npm 11.5.1 or newer and register the workflow as the trusted -publisher for each package: +Then install npm 11.11.0 or newer, matching the version the publish workflow +pins, and register the workflow as the trusted publisher for each package: ```bash npm install --global npm@latest -npm trust github @meter-mcp/sdk --file sdk-publish.yml --repo masterleopold/meter-sdk --env npm --allow-publish --yes -npm trust github @meter-mcp/mcp --file sdk-publish.yml --repo masterleopold/meter-sdk --env npm --allow-publish --yes -npm trust github @meter-mcp/adapters --file sdk-publish.yml --repo masterleopold/meter-sdk --env npm --allow-publish --yes +npm trust github @meter-mcp/sdk --file sdk-publish.yml --repo VoxTechnologies/meter-sdk --env npm --allow-publish --yes +npm trust github @meter-mcp/mcp --file sdk-publish.yml --repo VoxTechnologies/meter-sdk --env npm --allow-publish --yes +npm trust github @meter-mcp/adapters --file sdk-publish.yml --repo VoxTechnologies/meter-sdk --env npm --allow-publish --yes +npm trust github @meter-mcp/cli --file sdk-publish.yml --repo VoxTechnologies/meter-sdk --env npm --allow-publish --yes ``` The bootstrap publish is the only step that needs interactive npm credentials. @@ -48,14 +58,22 @@ Subsequent releases use GitHub OIDC and do not use a long-lived npm token. 3. Run `npm run verify` from a clean checkout. 4. Create and push the matching tag, for example `sdk-v0.2.0`. 5. Approve the protected `npm` environment deployment. -6. Confirm all three packages have the expected version, provenance, README, license, and repository link on npm. +6. Confirm all four packages have the expected version, provenance, README, license, and repository link on npm. -The publish workflow rejects mismatched package versions, dirty generated -output, failed tests, malformed tarballs, and tags that do not match the SDK -version. Packages publish in dependency order: SDK, MCP, adapters. +Before anything is published, the workflow runs `scripts/verify-sdk-release.mjs`, +which rejects package versions that have diverged from each other, a tag that is +not exactly `sdk-v`, and any manifest missing the `publishConfig` or +`repository.url` that trusted publishing is bound to. It then runs the full +`npm run verify` gate: build, tests, typecheck, package-metadata lint, OpenAPI +check, packed-consumer install, and `npm audit`. Packages publish in dependency +order: SDK, MCP, adapters, CLI. ## Recovery Never overwrite a published version. Fix the issue, add a changeset, and publish a new patch version. Use npm deprecation messages for a defective version instead of unpublishing it unless npm security policy requires removal. + +Re-running the workflow after a partial failure is safe: each publish step +queries the registry first and skips any version already there, so only the +packages that did not make it are published. diff --git a/package-lock.json b/package-lock.json index 29a3ed0..fc21736 100644 --- a/package-lock.json +++ b/package-lock.json @@ -800,13 +800,13 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.12.tgz", + "integrity": "sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==", "dev": true, "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { "hono": "^4" @@ -2308,9 +2308,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index 04a40c5..831a584 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "zod": "^3.25.76" }, "overrides": { - "tsup": { "esbuild": "^0.28.1" } + "tsup": { "esbuild": "^0.28.1" }, + "@hono/node-server": "^2.0.5", + "fast-uri": "^3.1.4" } }