diff --git a/.changeset/swift-pandas-scaffold.md b/.changeset/swift-pandas-scaffold.md new file mode 100644 index 00000000..a1a5720b --- /dev/null +++ b/.changeset/swift-pandas-scaffold.md @@ -0,0 +1,13 @@ +--- +"@connectum/cli": minor +--- + +feat: `connectum init` and `connectum generate service` — project scaffolding + +- **`connectum init`** scaffolds a production-ready standalone project, interactively (a `@clack/prompts` wizard) or fully from flags (`--yes` / CI / non-TTY). The base is fetched from the dogfooded `getting-started` example via a degit-style clone, so the starter layout stays in sync with a tested example instead of a drift-prone template copy; the selected modules are composed on top. +- **Modules:** OpenTelemetry (`--otel`), EventBus with an adapter (`--events nats|kafka|redpanda|redis|amqp`), auth (`--auth`, JWT + proto-driven authorization), service catalog (`--catalog`, typed `ctx.call`/`ctx.stream`), opt-in resilience interceptors (`--resilience timeout,retry,...`), and health/reflection toggles. Runtime (`node`/`bun`), package manager (`pnpm`/`npm`) and the Node execution model (`raw` `.ts` >= 25.2 vs `tsx` >= 22.13) are all first-class choices. +- **Deterministic interceptor order.** When several interceptor-adding modules are selected the composition root emits one canonical chain (outermost → innermost): OpenTelemetry → error handler → auth → validation → resilience → custom, with exactly one error handler. +- **Lifecycle fix baked in.** `buf generate` is chained into the generated `start` / `test` / `typecheck` scripts (not a pnpm `pre*` hook, which silently no-ops), so a fresh clone never fails with an unresolved `#gen/...` import. Standalone pnpm projects also get the `buf` build-approval that pnpm 11 requires. +- **`connectum generate service `** adds a service to an existing project: a starter proto plus a `defineService` skeleton whose rpc handlers throw `Code.Unimplemented` (a deliberate, documented trade-off — the handler-map key must still exist, so a later proto method addition remains a compile error). `--with-events` also scaffolds an event-handler service and an ack-by-default `EventRoute`. It never edits your `src/server.ts`; it prints the exact registration to add. +- **Generated tests are runtime-agnostic**: the e2e test uses the public in-process `createLocalClient` from `@connectum/testing` (no socket, identical on Node and Bun); event-enabled projects also get a broker-free `MemoryAdapter` smoke test. +- A CI scaffold matrix (`cli-scaffold-matrix`) scaffolds each named module combination and runs `buf generate` → typecheck → test, so a broken fragment fails CI. diff --git a/.github/workflows/cli-scaffold-matrix.yml b/.github/workflows/cli-scaffold-matrix.yml new file mode 100644 index 00000000..24f62890 --- /dev/null +++ b/.github/workflows/cli-scaffold-matrix.yml @@ -0,0 +1,90 @@ +name: CLI Scaffold Matrix + +# Anti-drift gate for `connectum init` (OpenSpec change cli-scaffolding, task 1.7 / D-9): +# scaffold a project for each named combo, then buf generate -> typecheck -> test, so a +# broken module fragment fails CI. Scaffolded projects install PUBLISHED @connectum/* +# from npm (consumer perspective) and fetch the base via tiged from GitHub. +# +# NOT covered here (named per the "no silent caps" rule): the tsx Node-exec model; pnpm +# beyond the one base cell; event adapters other than nats; and live-broker event +# integration (the generated events test is the broker-free MemoryAdapter smoke). + +on: + pull_request: + paths: + - "packages/cli/**" + - ".github/workflows/cli-scaffold-matrix.yml" + push: + branches: [main] + paths: + - "packages/cli/**" + +permissions: + contents: read + +jobs: + scaffold: + name: "init ${{ matrix.combo.name }}" + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + combo: + - { name: base-node-pnpm, pm: pnpm, args: "" } + - { name: base-node-npm, pm: npm, args: "" } + - { name: otel, pm: npm, args: "--otel" } + - { name: events-nats, pm: npm, args: "--events nats" } + - { name: auth, pm: npm, args: "--auth" } + - { name: catalog, pm: npm, args: "--catalog" } + - { name: kitchen-sink, pm: npm, args: "--otel --events nats --auth --catalog --resilience retry,timeout" } + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v4 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "25.2.0" + cache: pnpm + - name: Install + build the CLI + run: | + pnpm install --frozen-lockfile + pnpm --filter @connectum/cli build + - name: Scaffold a project + run: node packages/cli/dist/index.js init out --package-manager ${{ matrix.combo.pm }} --yes ${{ matrix.combo.args }} + - name: Install the scaffolded project + working-directory: out + run: ${{ matrix.combo.pm }} install + - name: Typecheck (runs buf generate first) + working-directory: out + run: ${{ matrix.combo.pm }} run typecheck + - name: Test + working-directory: out + run: ${{ matrix.combo.pm }} run test + + scaffold-bun: + name: "init bun" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v4 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "25.2.0" + cache: pnpm + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: latest + - name: Install + build the CLI + run: | + pnpm install --frozen-lockfile + pnpm --filter @connectum/cli build + - name: Scaffold a Bun project + run: node packages/cli/dist/index.js init out --runtime bun --package-manager npm --yes + - name: Install + test (bun test) + working-directory: out + run: | + npm install + npm run test diff --git a/packages/cli/README.md b/packages/cli/README.md index 36a2687d..520c1a22 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2,10 +2,12 @@ CLI tools for the Connectum gRPC/ConnectRPC framework. -**Command-line tooling for Connectum services — synchronize proto types from a running server via gRPC Server Reflection.** +**Command-line tooling for Connectum services — scaffold a new project, add services, and synchronize proto types from a running server.** ## Features +- `connectum init` — scaffold a production-ready project (interactive wizard or flags), composing optional modules: OpenTelemetry, EventBus (NATS / Kafka / Redpanda / Redis / AMQP), auth (JWT + proto authorization), service catalog, resilience interceptors +- `connectum generate service` — add a service (rpc and/or event handlers) to an existing project - `connectum proto sync` — generate TypeScript proto stubs from a live server via gRPC Server Reflection - Dry-run mode to inspect discovered services and files without generating code - Programmatic API (`fetchReflectionData`, `fetchFileDescriptorSetBinary`, `executeProtoSync`) for custom tooling @@ -21,12 +23,69 @@ Requires Node.js >= 22.13.0. ## Quick Start ```bash +# Scaffold a new project (interactive) +npx @connectum/cli init + +# ...or non-interactively, with modules +npx @connectum/cli init payments --package-manager pnpm --otel --events nats --auth --yes + +# Add a service to an existing project +connectum generate service billing --with-events + # Generate TypeScript types from a running server with reflection enabled connectum proto sync --from localhost:5000 --out ./gen ``` ## Commands +### `connectum init` + +Scaffold a new standalone Connectum project. The base is fetched from the dogfooded +`getting-started` example (so the starter layout stays in sync with a tested example) +and the selected modules are composed on top. Requires network access on first run. + +`buf generate` is chained into the generated `start` / `test` / `typecheck` scripts, so +the code under `gen/` is always current — no "cannot find module `#gen/...`" wall. + +**Usage:** + +```bash +connectum init [name] [options] +``` + +| Flag | Values | Description | +|------|--------|-------------| +| `--runtime` | `node` (default), `bun` | Target runtime | +| `--package-manager` | `pnpm` (default), `npm` | Package manager | +| `--node-exec` | `raw` (default), `tsx` | `raw` runs `.ts` directly (Node >= 25.2); `tsx` runs on Node >= 22.13 | +| `--otel` | — | OpenTelemetry interceptor + provider lifecycle | +| `--events` | `nats`, `kafka`, `redpanda`, `redis`, `amqp` | EventBus with the chosen adapter | +| `--auth` | — | JWT authentication + proto-driven authorization | +| `--catalog` | — | Service catalog (typed `ctx.call` / `ctx.stream`) | +| `--resilience` | `timeout,bulkhead,circuitBreaker,retry,fallback` | Opt-in resilience interceptors | +| `--healthcheck` / `--no-healthcheck` | — | gRPC health protocol (default on) | +| `--reflection` / `--no-reflection` | — | gRPC server reflection (default on) | +| `--sample` / `--no-sample` | — | Runnable sample Greeter service (default on) | +| `--yes`, `-y` | — | Non-interactive (flags + defaults) | +| `--force` | — | Overwrite existing files | + +When several interceptor-adding modules are selected, the composition root emits one +consistent order (outermost → innermost): **OpenTelemetry → error handler → auth → +validation → resilience → custom**. + +### `connectum generate service` + +Add a service to an existing project. + +```bash +connectum generate service [--with-events] [--force] +``` + +Scaffolds `proto//v1/.proto` and `src/services/Service.ts` (a +`defineService` skeleton whose rpc handlers throw `Code.Unimplemented`; with +`--with-events`, also an `EventRoute` with an ack-by-default handler). It never edits +your `src/server.ts` — it prints the exact registration line to add. + ### `connectum proto sync` Sync proto types from a running Connectum server via gRPC Server Reflection. diff --git a/packages/cli/package.json b/packages/cli/package.json index d909360d..45557956 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -17,9 +17,21 @@ "types": "./dist/commands/proto-sync.d.ts", "default": "./dist/commands/proto-sync.js" }, + "./commands/init": { + "types": "./dist/commands/init.d.ts", + "default": "./dist/commands/init.js" + }, + "./commands/generate-service": { + "types": "./dist/commands/generate-service.d.ts", + "default": "./dist/commands/generate-service.js" + }, "./utils/reflection": { "types": "./dist/utils/reflection.d.ts", "default": "./dist/utils/reflection.js" + }, + "./utils/emit": { + "types": "./dist/utils/emit.d.ts", + "default": "./dist/utils/emit.js" } }, "files": [ @@ -62,10 +74,12 @@ }, "dependencies": { "@bufbuild/protobuf": "catalog:", + "@clack/prompts": "^1.7.0", "@connectrpc/connect": "catalog:", "@connectrpc/connect-node": "catalog:", "@lambdalisue/connectrpc-grpcreflect": "^0.5.0", - "citty": "^0.2.2" + "citty": "^0.2.2", + "tiged": "^2.12.8" }, "devDependencies": { "@biomejs/biome": "catalog:", diff --git a/packages/cli/src/commands/generate-service.ts b/packages/cli/src/commands/generate-service.ts new file mode 100644 index 00000000..1dbbf042 --- /dev/null +++ b/packages/cli/src/commands/generate-service.ts @@ -0,0 +1,95 @@ +/** + * `connectum generate service ` — scaffold a service with empty handlers. + * + * Scaffolds a starter proto + a `defineService` skeleton (rpc stubs throw + * Unimplemented, D-6) and, with `--with-events`, an event-handler `EventRoute`. + * Per D-11 the composition root (`src/server.ts`) is user-owned and NOT edited — + * the command prints the exact registration edit instead. Refuse-to-clobber. + * + * @module commands/generate-service + */ + +import { resolve } from "node:path"; +import { defineCommand } from "citty"; +import { buildServiceFiles, packageName, registrationMessage } from "../scaffold/generateService.ts"; +import { emitFiles } from "../utils/emit.ts"; + +/** + * Options for the `generate service` pipeline. + */ +export interface GenerateServiceOptions { + /** Service name. */ + name: string; + /** Also scaffold an event-handler service. */ + withEvents?: boolean | undefined; + /** Overwrite existing files instead of skipping them. */ + force?: boolean | undefined; + /** Target directory (defaults to cwd). */ + cwd?: string | undefined; +} + +/** + * Execute the `generate service` pipeline. + * + * @param options - Generate-service configuration + */ +export async function executeGenerateService(options: GenerateServiceOptions): Promise { + const name = options.name.trim(); + if (name === "") { + throw new Error("connectum generate service: a service name is required (e.g. `connectum generate service billing`)"); + } + // A name with no alphanumerics (e.g. "!!!") reduces to an empty proto package and an + // empty service identifier, which would emit a malformed `package .v1;` — reject it. + if (packageName(name) === "") { + throw new Error(`connectum generate service: "${options.name}" has no alphanumeric characters — cannot derive a proto package or service name.`); + } + const withEvents = options.withEvents ?? false; + const targetDir = resolve(options.cwd ?? process.cwd()); + + const files = buildServiceFiles(name, withEvents); + const result = emitFiles(targetDir, files, { force: options.force ?? false }); + + console.log(`Generated ${result.written.length} file(s) for service "${name}":`); + for (const path of result.written) { + console.log(` + ${path}`); + } + if (result.skipped.length > 0) { + console.log(`Skipped ${result.skipped.length} existing file(s): ${result.skipped.join(", ")}`); + } + console.log(""); + console.log(registrationMessage(name, withEvents)); +} + +/** + * citty command definition for `connectum generate service`. + */ +export const generateServiceCommand = defineCommand({ + meta: { + name: "service", + description: "Scaffold a service with empty rpc/event handlers", + }, + args: { + name: { + type: "positional", + description: "Service name", + required: true, + }, + "with-events": { + type: "boolean", + description: "Also scaffold an event-handler service", + default: false, + }, + force: { + type: "boolean", + description: "Overwrite existing files", + default: false, + }, + }, + async run({ args }) { + await executeGenerateService({ + name: args.name, + withEvents: args["with-events"], + force: args.force, + }); + }, +}); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts new file mode 100644 index 00000000..93e2c5cc --- /dev/null +++ b/packages/cli/src/commands/init.ts @@ -0,0 +1,211 @@ +/** + * `connectum init` — scaffold a new standalone Connectum project. + * + * Pipeline (OpenSpec change cli-scaffolding, Phase 1): + * 1. Resolve flags into a validated {@link ScaffoldConfig}. + * 2. Fetch the source-of-truth base (`getting-started`) into a temp dir (D-13). + * 3. Transform it (de-monorepo, runtime/PM adjustments, lifecycle-fix, in-process + * e2e test) into the final project file map (pure). + * 4. Emit into the target directory (refuse-to-clobber). + * + * Interactive module selection (TUI + fragments) lands in Phase 2. Dependency + * install / `git init` are left to the developer (printed as next steps) for now. + * + * @module commands/init + */ + +import { existsSync, mkdtempSync, readdirSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { defineCommand } from "citty"; +import { resolveConfig } from "../scaffold/config.ts"; +import type { CloneFn } from "../scaffold/fetchBase.ts"; +import { fetchBase, readTree } from "../scaffold/fetchBase.ts"; +import { collectConfig } from "../scaffold/prompts.ts"; +import { transformBase } from "../scaffold/transform.ts"; +import { emitFiles } from "../utils/emit.ts"; + +/** + * Options for the `init` pipeline. + */ +export interface InitOptions { + /** Project name / target directory. */ + name?: string | undefined; + /** Target runtime: `node` (default) or `bun`. */ + runtime?: string | undefined; + /** Package manager: `pnpm` (default) or `npm`. */ + packageManager?: string | undefined; + /** Node execution model: `raw` (default) or `tsx` (Node runtime only). */ + nodeExec?: string | undefined; + /** Emit a runnable sample service (default true) or config-only. */ + sample?: boolean | undefined; + /** Enable the OpenTelemetry module. */ + otel?: boolean | undefined; + /** Enable the EventBus module with the given adapter (nats|kafka|redpanda|redis|amqp). */ + events?: string | undefined; + /** Enable the auth module (JWT + proto authorization). */ + auth?: boolean | undefined; + /** Comma-separated resilience interceptors (timeout,bulkhead,circuitBreaker,retry,fallback). */ + resilience?: string | undefined; + /** Include the gRPC health protocol (default true; pass --no-healthcheck to omit). */ + healthcheck?: boolean | undefined; + /** Include gRPC reflection (default true; pass --no-reflection to omit). */ + reflection?: boolean | undefined; + /** Add the service catalog (typed ctx.call). */ + catalog?: boolean | undefined; + /** Non-interactive mode (skip the TUI; use flags/defaults). */ + yes?: boolean | undefined; + /** Base git ref to fetch (advanced; defaults to the pinned example ref). */ + ref?: string | undefined; + /** Overwrite existing files instead of refusing. */ + force?: boolean | undefined; + /** Injected clone function (tests supply a local-copy stub; production uses tiged). */ + clone?: CloneFn | undefined; +} + +/** + * Execute the `init` pipeline. + * + * @param options - Init configuration + */ +export async function executeInit(options: InitOptions): Promise { + const raw = await collectConfig({ + name: options.name, + runtime: options.runtime, + packageManager: options.packageManager, + nodeExec: options.nodeExec, + sample: options.sample, + otel: options.otel, + events: options.events, + auth: options.auth, + resilience: options.resilience, + healthcheck: options.healthcheck, + reflection: options.reflection, + catalog: options.catalog, + yes: options.yes, + }); + const config = resolveConfig(raw); + + const targetDir = resolve(process.cwd(), config.name); + if (existsSync(targetDir)) { + // Guard before readdirSync: a path that exists but is a file would throw a raw ENOTDIR. + if (!statSync(targetDir).isDirectory()) { + throw new Error(`connectum init: "${config.name}" already exists and is not a directory.`); + } + if (!options.force && readdirSync(targetDir).length > 0) { + throw new Error(`connectum init: target directory "${config.name}" already exists and is not empty (use --force to overwrite).`); + } + } + + const tmp = mkdtempSync(join(tmpdir(), "connectum-init-base-")); + try { + console.log(`Fetching base project (${config.runtime}/${config.packageManager})...`); + await fetchBase(tmp, { ref: options.ref, clone: options.clone }); + + const baseFiles = readTree(tmp); + const finalFiles = transformBase(baseFiles, config); + const result = emitFiles(targetDir, finalFiles, { force: options.force ?? false }); + + console.log(`Scaffolded ${result.written.length} files into ${config.name}/`); + if (result.skipped.length > 0) { + console.log(`Skipped ${result.skipped.length} existing file(s): ${result.skipped.join(", ")}`); + } + console.log(""); + console.log("Next steps:"); + console.log(` cd ${config.name}`); + console.log(` ${config.packageManager} install`); + console.log(` ${config.packageManager} run start`); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +} + +/** + * citty command definition for `connectum init`. + */ +export const initCommand = defineCommand({ + meta: { + name: "init", + description: "Scaffold a new Connectum project", + }, + args: { + name: { + type: "positional", + description: "Project name / target directory", + required: false, + }, + runtime: { + type: "string", + description: "Target runtime: node (default) or bun", + }, + "package-manager": { + type: "string", + description: "Package manager: pnpm (default) or npm", + }, + "node-exec": { + type: "string", + description: "Node execution model: raw (default, Node >=25.2) or tsx (Node >=22.13)", + }, + sample: { + type: "boolean", + description: "Emit a runnable sample service (default: true)", + }, + otel: { + type: "boolean", + description: "Add OpenTelemetry instrumentation", + }, + events: { + type: "string", + description: "Add EventBus with an adapter: nats | kafka | redpanda | redis | amqp", + }, + auth: { + type: "boolean", + description: "Add JWT authentication + proto authorization", + }, + resilience: { + type: "string", + description: "Enable resilience interceptors (comma list: timeout,bulkhead,circuitBreaker,retry,fallback)", + }, + healthcheck: { + type: "boolean", + description: "Include the gRPC health protocol (default: true)", + }, + reflection: { + type: "boolean", + description: "Include gRPC server reflection (default: true)", + }, + catalog: { + type: "boolean", + description: "Add the service catalog (typed ctx.call / ctx.stream)", + }, + yes: { + type: "boolean", + alias: "y", + description: "Non-interactive mode (skip prompts; use flags/defaults)", + default: false, + }, + force: { + type: "boolean", + description: "Overwrite existing files", + default: false, + }, + }, + async run({ args }) { + await executeInit({ + name: args.name, + runtime: args.runtime, + packageManager: args["package-manager"], + nodeExec: args["node-exec"], + sample: args.sample, + otel: args.otel, + events: args.events, + auth: args.auth, + resilience: args.resilience, + healthcheck: args.healthcheck, + reflection: args.reflection, + catalog: args.catalog, + yes: args.yes, + force: args.force, + }); + }, +}); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 1d08a572..6036fe2c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -4,7 +4,9 @@ * CLI tools for Connectum framework. * * Commands: - * - connectum proto sync -- Sync proto types from a running server via reflection + * - connectum init -- Scaffold a new Connectum project (work in progress) + * - connectum proto sync -- Sync proto types from a running server via reflection + * - connectum generate service -- Scaffold a service with empty handlers (work in progress) * * @module @connectum/cli * @mergeModuleWith @@ -12,6 +14,8 @@ import { readFileSync } from "node:fs"; import { defineCommand, runMain } from "citty"; +import { generateServiceCommand } from "./commands/generate-service.ts"; +import { initCommand } from "./commands/init.ts"; import { protoSyncCommand } from "./commands/proto-sync.ts"; // Read the version from package.json so `connectum --version` always reports the @@ -25,6 +29,7 @@ const main = defineCommand({ description: "CLI tools for Connectum gRPC/ConnectRPC framework", }, subCommands: { + init: initCommand, proto: defineCommand({ meta: { name: "proto", @@ -34,6 +39,15 @@ const main = defineCommand({ sync: protoSyncCommand, }, }), + generate: defineCommand({ + meta: { + name: "generate", + description: "Code generation / scaffolding commands", + }, + subCommands: { + service: generateServiceCommand, + }, + }), }, }); diff --git a/packages/cli/src/scaffold/authFragment.ts b/packages/cli/src/scaffold/authFragment.ts new file mode 100644 index 00000000..5f299484 --- /dev/null +++ b/packages/cli/src/scaffold/authFragment.ts @@ -0,0 +1,50 @@ +/** + * The `auth` module fragment for `connectum init` (task 2.4). Adds `@connectum/auth` + * (JWT authentication + proto-driven authorization). The auth option proto is resolved + * from `node_modules/@connectum/auth/proto` as a **second buf module** (not vendored), + * so `install` must run before `buf generate` — which the lifecycle-fix guarantees + * (buf generate runs on test/start, after install). + * + * APIs verified against `@connectum/auth` / `@connectum/auth/proto`. + * + * @module scaffold/authFragment + */ + +/** The buf module path for the (installed) auth option proto. */ +export const AUTH_BUF_MODULE = "node_modules/@connectum/auth/proto"; + +/** `src/auth.ts` — builds the JWT + proto-authz interceptor chain. */ +export function generateAuthFile(): string { + return `/** + * Authentication (JWT via JWKS) + authorization (driven by proto \`(connectum.auth.v1.*)\` + * annotations). Configure via env: JWKS_URI, JWT_ISSUER, JWT_AUDIENCE. + * + * @module auth + */ + +import { createJwtAuthInterceptor } from "@connectum/auth"; +import { createProtoAuthzInterceptor, getPublicMethods } from "@connectum/auth/proto"; +import type { Interceptor } from "@connectrpc/connect"; +import { GreeterService } from "#gen/greeter/v1/greeter_pb.ts"; + +// Methods annotated \`(connectum.auth.v1.method_auth) = { public: true }\` in your proto +// are read here and skipped by the JWT interceptor. Add your services to this list. +const publicMethods = getPublicMethods([GreeterService]); + +/** + * Build the auth interceptor chain: JWT authentication followed by proto-driven + * authorization (deny-by-default — annotate methods/services to allow). + */ +export function buildAuthInterceptors(): Interceptor[] { + const jwtAuth = createJwtAuthInterceptor({ + jwksUri: process.env.JWKS_URI ?? "http://localhost:8080/.well-known/jwks.json", + issuer: process.env.JWT_ISSUER, + audience: process.env.JWT_AUDIENCE, + algorithms: ["RS256"], + skipMethods: [...publicMethods, "grpc.health.v1.Health/*", "grpc.reflection.v1.ServerReflection/*"], + }); + const authz = createProtoAuthzInterceptor(); + return [jwtAuth, authz]; +} +`; +} diff --git a/packages/cli/src/scaffold/bufConfig.ts b/packages/cli/src/scaffold/bufConfig.ts new file mode 100644 index 00000000..6e420a1a --- /dev/null +++ b/packages/cli/src/scaffold/bufConfig.ts @@ -0,0 +1,70 @@ +/** + * Generate `buf.yaml` from the module set. + * + * Composes the module list (the project's `proto`, plus the auth option proto from + * `node_modules` when auth is enabled) and the lint `except` list. Event-handler + * services and the auth/events option protos intentionally violate STANDARD lint + * (SERVICE_SUFFIX / RPC_*), so the relevant excepts are added to keep `buf lint` green. + * + * @module scaffold/bufConfig + */ + +import { AUTH_BUF_MODULE } from "./authFragment.ts"; +import type { ScaffoldConfig } from "./types.ts"; + +export function generateBufYaml(config: ScaffoldConfig): string { + const modules = [" - path: proto"]; + if (config.modules.auth) { + modules.push(` - path: ${AUTH_BUF_MODULE}`); + } + + const excepts = new Set(); + if (config.modules.events) { + excepts.add("SERVICE_SUFFIX"); + } + if (config.modules.events || config.modules.auth) { + excepts.add("RPC_REQUEST_STANDARD_NAME"); + excepts.add("RPC_RESPONSE_STANDARD_NAME"); + excepts.add("RPC_REQUEST_RESPONSE_UNIQUE"); + } + const exceptBlock = excepts.size > 0 ? `\n except:\n${[...excepts].map((e) => ` - ${e}`).join("\n")}` : ""; + + return `version: v2 +modules: +${modules.join("\n")} +lint: + use: + - STANDARD${exceptBlock} +breaking: + use: + - FILE +`; +} + +/** + * Generate `buf.gen.yaml`. protoc-gen-es always; the catalog plugin + * (`protoc-gen-connectum-catalog`, `strategy: all`) is added when `catalog` is enabled + * so `serviceCatalog` / typed `ctx.call` are generated into a single `catalog.gen.ts`. + */ +export function generateBufGenYaml(config: ScaffoldConfig): string { + const catalogPlugin = config.modules.catalog + ? ` + - local: protoc-gen-connectum-catalog + strategy: all + out: gen + opt: + - target=ts + - import_extension=.ts` + : ""; + return `version: v2 +clean: true +inputs: + - directory: proto +plugins: + - local: protoc-gen-es + out: gen + opt: + - target=ts + - import_extension=.ts${catalogPlugin} +`; +} diff --git a/packages/cli/src/scaffold/config.ts b/packages/cli/src/scaffold/config.ts new file mode 100644 index 00000000..01caeda6 --- /dev/null +++ b/packages/cli/src/scaffold/config.ts @@ -0,0 +1,92 @@ +/** + * Resolve raw CLI input (flags / prompt answers) into a validated {@link ScaffoldConfig}. + * + * Pure and testable: both the non-interactive flag path and the interactive TUI + * collapse to the same `RawInput`, so validation and defaulting live in one place. + * + * @module scaffold/config + */ + +import type { EventAdapter, NodeExec, PackageManager, ResilienceInterceptor, Runtime, ScaffoldConfig } from "./types.ts"; + +const RUNTIMES: readonly Runtime[] = ["node", "bun"]; +const PACKAGE_MANAGERS: readonly PackageManager[] = ["pnpm", "npm"]; +const NODE_EXECS: readonly NodeExec[] = ["raw", "tsx"]; +const EVENT_ADAPTERS: readonly EventAdapter[] = ["nats", "kafka", "redpanda", "redis", "amqp"]; +const RESILIENCE: readonly ResilienceInterceptor[] = ["timeout", "bulkhead", "circuitBreaker", "retry", "fallback"]; + +/** Raw, unvalidated input from flags or prompts (all optional except that name is required at resolve time). */ +export interface RawInput { + name?: string | undefined; + runtime?: string | undefined; + packageManager?: string | undefined; + nodeExec?: string | undefined; + sample?: boolean | undefined; + otel?: boolean | undefined; + /** Event adapter name, or undefined/empty to disable the events module. */ + events?: string | undefined; + auth?: boolean | undefined; + /** Comma-separated resilience interceptors (e.g. "retry,timeout"). */ + resilience?: string | undefined; + healthcheck?: boolean | undefined; + reflection?: boolean | undefined; + catalog?: boolean | undefined; +} + +function oneOf(value: string | undefined, allowed: readonly T[], field: string, fallback: T): T { + if (value === undefined) { + return fallback; + } + if (!(allowed as readonly string[]).includes(value)) { + throw new Error(`connectum init: invalid --${field} "${value}" (expected one of: ${allowed.join(", ")})`); + } + return value as T; +} + +/** + * Validate + default raw input into a resolved config. + * + * @throws Error if `name` is missing or any enum value is invalid. + */ +export function resolveConfig(input: RawInput): ScaffoldConfig { + const name = (input.name ?? "").trim(); + if (name === "") { + throw new Error("connectum init: a project name is required (e.g. `connectum init my-service`)"); + } + + const runtime = oneOf(input.runtime, RUNTIMES, "runtime", "node"); + const packageManager = oneOf(input.packageManager, PACKAGE_MANAGERS, "package-manager", "pnpm"); + const nodeExec = oneOf(input.nodeExec, NODE_EXECS, "node-exec", "raw"); + + // `--events` passed with no value is an explicit opt-in that names no adapter — fail + // loudly instead of silently disabling the module. (The wizard resolves this case by + // asking for an adapter, so an empty value only reaches here in non-interactive mode.) + if (input.events !== undefined && input.events.trim() === "") { + throw new Error(`connectum init: --events requires an adapter (expected one of: ${EVENT_ADAPTERS.join(", ")})`); + } + const eventsRaw = (input.events ?? "").trim(); + const events = eventsRaw === "" ? undefined : { adapter: oneOf(eventsRaw, EVENT_ADAPTERS, "events", "nats") }; + + const resilience = (input.resilience ?? "") + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0) + .map((s) => oneOf(s, RESILIENCE, "resilience", "retry")); + + return { + name, + runtime, + packageManager, + nodeExec, + sample: input.sample ?? true, + modules: { + otel: input.otel ?? false, + events, + auth: input.auth ?? false, + resilience, + healthcheck: input.healthcheck ?? true, + reflection: input.reflection ?? true, + catalog: input.catalog ?? false, + }, + }; +} diff --git a/packages/cli/src/scaffold/eventsFragment.ts b/packages/cli/src/scaffold/eventsFragment.ts new file mode 100644 index 00000000..3002a0d6 --- /dev/null +++ b/packages/cli/src/scaffold/eventsFragment.ts @@ -0,0 +1,159 @@ +/** + * The `events` module fragment for `connectum init` (OpenSpec change cli-scaffolding, + * task 2.2). Adds `@connectum/events` + a transport adapter, an event-handler service + * (proto + `EventRoute`), and the EventBus wiring. + * + * Adapter constructors and the event-handler proto shape are lifted from the dogfooded + * `examples/with-events-*`; the option proto is vendored (as those examples do). + * + * @module scaffold/eventsFragment + */ + +import type { EventAdapter, ScaffoldConfig } from "./types.ts"; + +interface AdapterSpec { + /** npm package providing the adapter. */ + pkg: string; + /** Named export (constructor function). */ + importName: string; + /** Renders the adapter constructor call for a project named `name`. */ + ctor: (name: string) => string; +} + +const ADAPTERS: Record = { + nats: { + pkg: "@connectum/events-nats", + importName: "NatsAdapter", + ctor: () => 'NatsAdapter({ servers: process.env.NATS_SERVERS ?? "nats://localhost:4222" })', + }, + kafka: { + pkg: "@connectum/events-kafka", + importName: "KafkaAdapter", + ctor: (name) => `KafkaAdapter({ brokers: (process.env.KAFKA_BROKERS ?? "localhost:9092").split(","), clientId: ${JSON.stringify(name)} })`, + }, + redpanda: { + pkg: "@connectum/events-kafka", + importName: "KafkaAdapter", + ctor: (name) => `KafkaAdapter({ brokers: (process.env.REDPANDA_BROKERS ?? "localhost:9092").split(","), clientId: ${JSON.stringify(name)} })`, + }, + redis: { + pkg: "@connectum/events-redis", + importName: "RedisAdapter", + ctor: () => 'RedisAdapter({ url: process.env.REDIS_URL ?? "redis://localhost:6379" })', + }, + amqp: { + pkg: "@connectum/events-amqp", + importName: "AmqpAdapter", + ctor: (name) => `AmqpAdapter({ url: process.env.AMQP_URL ?? "amqp://localhost:5672", exchange: ${JSON.stringify(name)} })`, + }, +}; + +/** The npm package a given adapter needs (for package.json dependencies). */ +export function adapterPackage(adapter: EventAdapter): string { + return ADAPTERS[adapter].pkg; +} + +/** Vendored option proto (proto2) — same content @connectum/events publishes. */ +export function generateEventsOptionsProto(): string { + return `syntax = "proto2"; + +package connectum.events.v1; + +import "google/protobuf/descriptor.proto"; + +// Method-level topic override for event handlers. +message EventOptions { + // Custom topic name; defaults to the input message's typeName when unset. + optional string topic = 1; +} + +extend google.protobuf.MethodOptions { + optional EventOptions event = 50102; +} +`; +} + +/** Demo event message + event-handler service for the sample Greeter. */ +export function generateEventsProto(): string { + return `syntax = "proto3"; + +package greeter.v1; + +import "google/protobuf/empty.proto"; +import "connectum/events/v1/options.proto"; + +// A demo event. Replace with your own event messages. +message GreetingSent { + string name = 1; + string message = 2; +} + +// Event-handler service: one rpc per subscribed event, each returning Empty. +service GreeterEventHandlers { + rpc OnGreetingSent(GreetingSent) returns (google.protobuf.Empty) { + option (connectum.events.v1.event).topic = "greeter.greeting-sent"; + } +} +`; +} + +/** `src/EventBus.ts` — constructs the EventBus with the chosen adapter. */ +export function generateEventBusFile(config: ScaffoldConfig, adapter: EventAdapter): string { + const spec = ADAPTERS[adapter]; + return `import { createEventBus } from "@connectum/events"; +import { ${spec.importName} } from "${spec.pkg}"; +import { greeterEventRoutes } from "./services/greeterEvents.ts"; + +export const greeterEventBus = createEventBus({ + adapter: ${spec.ctor(config.name)}, + routes: [greeterEventRoutes], + group: ${JSON.stringify(config.name)}, + middleware: { retry: { maxRetries: 3, backoff: "exponential" } }, +}); +`; +} + +/** + * `tests/e2e/events.test.ts` — a broker-free smoke test: registers the EventRoute on an + * in-memory bus and exercises the start/stop lifecycle (no external broker needed). Uses + * the runtime-appropriate test runner import (D-7 pattern). + */ +export function generateEventsTest(runtime: "node" | "bun"): string { + const runnerImport = runtime === "bun" ? 'import { describe, it } from "bun:test";' : 'import { describe, it } from "node:test";'; + return `/** + * Smoke test for the event wiring: the EventRoute registers on an in-memory bus and + * the bus start/stop lifecycle works — no external broker required. + */ + +import assert from "node:assert/strict"; +${runnerImport} +import { createEventBus, MemoryAdapter } from "@connectum/events"; +import { greeterEventRoutes } from "#services/greeterEvents.ts"; + +describe("greeter events", () => { + it("registers handlers on an in-memory bus and starts/stops cleanly", async () => { + const bus = createEventBus({ adapter: MemoryAdapter(), routes: [greeterEventRoutes], group: "test" }); + await bus.start(); + await bus.stop(); + assert.ok(true); + }); +}); +`; +} + +/** \`src/services/greeterEvents.ts\` — the EventRoute with an empty (ack) handler. */ +export function generateEventRouteFile(): string { + return `import type { EventRoute } from "@connectum/events"; +import { GreeterEventHandlers } from "#gen/greeter/v1/events_pb.ts"; + +export const greeterEventRoutes: EventRoute = (events) => { + events.service(GreeterEventHandlers, { + async onGreetingSent(event, ctx) { + // TODO: handle the GreetingSent event (topic: greeter.greeting-sent). + console.log(\`[greeterEvents] GreetingSent: \${event.name} — \${event.message}\`); + await ctx.ack(); + }, + }); +}; +`; +} diff --git a/packages/cli/src/scaffold/fetchBase.ts b/packages/cli/src/scaffold/fetchBase.ts new file mode 100644 index 00000000..c49893e4 --- /dev/null +++ b/packages/cli/src/scaffold/fetchBase.ts @@ -0,0 +1,90 @@ +/** + * Base-project fetcher for `connectum init`. + * + * Per OpenSpec change cli-scaffolding (D-13, task 0.3), the inert base project is + * NOT vendored inside the CLI — it is fetched live from the source-of-truth example + * `Connectum-Framework/examples/getting-started` via a degit-style clone (`tiged`), + * so there is a single source of truth and no drift-prone duplicate. + * + * The clone function is injectable so the composition/transform logic can be unit + * tested against a local fixture without any network access. + * + * NOTE: the `examples` repository does not yet publish release tags, so the default + * ref is `main`. When examples begins tagging releases, pin `DEFAULT_BASE_REF` (per + * CLI release) to the matching tag so a broken `main` cannot break every `init`. + * + * @module scaffold/fetchBase + */ + +import { lstatSync, readdirSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; + +/** Source-of-truth base repo + subdirectory (without the `#ref` suffix). */ +export const BASE_SOURCE = "Connectum-Framework/examples/getting-started"; + +/** Default git ref to fetch (see module note: examples has no tags yet). */ +export const DEFAULT_BASE_REF = "main"; + +/** + * Clones `source` (a tiged-style `owner/repo/subdir#ref` spec) into `dest`. + * Extracted as a type so it can be replaced in tests with a local-copy stub. + */ +export type CloneFn = (source: string, dest: string) => Promise; + +/** Default {@link CloneFn} backed by `tiged`. */ +export const tigedClone: CloneFn = async (source, dest) => { + // Imported lazily so unit tests that inject a stub never load tiged (and never + // touch the network). + const { default: tiged } = await import("tiged"); + const emitter = tiged(source, { cache: false, force: true, verbose: false }); + await emitter.clone(dest); +}; + +/** + * Fetch the base project into `dest`. + * + * @throws Error with a clear message if the clone fails (e.g. network / GitHub + * unreachable) — never a raw crash. + */ +export async function fetchBase(dest: string, options: { ref?: string | undefined; clone?: CloneFn | undefined } = {}): Promise { + const ref = options.ref ?? DEFAULT_BASE_REF; + const clone = options.clone ?? tigedClone; + const source = `${BASE_SOURCE}#${ref}`; + try { + await clone(source, dest); + } catch (cause) { + const reason = cause instanceof Error ? cause.message : String(cause); + throw new Error(`connectum init: failed to fetch the base project from "${source}". Check network / GitHub availability and try again. Cause: ${reason}`, { cause }); + } +} + +/** + * Recursively read a directory tree into a `Map` with + * POSIX-style forward-slash keys (so transforms are platform-independent). + * + * @param root - Directory to read + * @returns Map of relative path -> UTF-8 content + */ +export function readTree(root: string): Map { + const files = new Map(); + const walk = (dir: string): void => { + for (const entry of readdirSync(dir)) { + const abs = join(dir, entry); + const stat = lstatSync(abs); + // Skip symlinks: getting-started's `.pnpmfile.cjs` is a symlink into the + // examples-monorepo root (broken in a subdir clone), and monorepo symlinks + // are dropped from a standalone project anyway. + if (stat.isSymbolicLink()) { + continue; + } + if (stat.isDirectory()) { + walk(abs); + continue; + } + const rel = relative(root, abs).split(/[\\/]/).join("/"); + files.set(rel, readFileSync(abs, "utf8")); + } + }; + walk(root); + return files; +} diff --git a/packages/cli/src/scaffold/generateService.ts b/packages/cli/src/scaffold/generateService.ts new file mode 100644 index 00000000..0754fdf6 --- /dev/null +++ b/packages/cli/src/scaffold/generateService.ts @@ -0,0 +1,144 @@ +/** + * `connectum generate service ` generators (OpenSpec change cli-scaffolding, + * Phase 3). Scaffolds a service proto + a `defineService` skeleton with empty + * handlers (D-6), optionally an event-handler `EventRoute` (`--with-events`), and + * returns the manual registration edit to print (D-11 — the composition root is + * user-owned and never edited). + * + * @module scaffold/generateService + */ + +import { generateEventsOptionsProto } from "./eventsFragment.ts"; + +/** Proto/package-safe lowercase identifier (strips non-alphanumerics). */ +export function packageName(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +/** PascalCase from an arbitrary name (`my-service` -> `MyService`). */ +export function pascalCase(name: string): string { + return name + .split(/[^a-zA-Z0-9]+/) + .filter((p) => p.length > 0) + .map((p) => p.charAt(0).toUpperCase() + p.slice(1)) + .join(""); +} + +/** camelCase from an arbitrary name (`my-service` -> `myService`). */ +export function camelCase(name: string): string { + const p = pascalCase(name); + return p.charAt(0).toLowerCase() + p.slice(1); +} + +/** Generate the service proto (a starter with one demo rpc; optional event handler). */ +export function generateServiceProto(name: string, withEvents: boolean): string { + const pkg = packageName(name); + const Svc = pascalCase(name); + const eventsImport = withEvents ? 'import "google/protobuf/empty.proto";\nimport "connectum/events/v1/options.proto";\n\n' : ""; + const eventsBlock = withEvents + ? ` +// A demo event. Replace with your own event messages. +message ${Svc}Event { + string id = 1; +} + +// Event-handler service: one rpc per subscribed event, each returning Empty. +service ${Svc}EventHandlers { + rpc On${Svc}Event(${Svc}Event) returns (google.protobuf.Empty) { + option (connectum.events.v1.event).topic = "${pkg}.event"; + } +} +` + : ""; + return `syntax = "proto3"; + +package ${pkg}.v1; + +${eventsImport}message PingRequest { + string message = 1; +} +message PingResponse { + string message = 1; +} + +service ${Svc}Service { + rpc Ping(PingRequest) returns (PingResponse); +} +${eventsBlock}`; +} + +/** Generate the service implementation module (`defineService` + optional EventRoute). */ +export function generateServiceImpl(name: string, withEvents: boolean): string { + const pkg = packageName(name); + const Svc = pascalCase(name); + const svcConst = `${camelCase(name)}Service`; + const genPath = `#gen/${pkg}/v1/${pkg}_pb.ts`; + + const rpc = `import { defineService } from "@connectum/core"; +import { Code, ConnectError } from "@connectrpc/connect"; +import { ${Svc}Service } from "${genPath}"; + +export const ${svcConst} = defineService(${Svc}Service, { + ping: (_req, _ctx) => { + // TODO: implement Ping. + throw new ConnectError("Ping not implemented", Code.Unimplemented); + }, +}); +`; + + if (!withEvents) { + return rpc; + } + + const routesConst = `${camelCase(name)}EventRoutes`; + return `${rpc} +import type { EventRoute } from "@connectum/events"; +import { ${Svc}EventHandlers } from "${genPath}"; + +export const ${routesConst}: EventRoute = (events) => { + events.service(${Svc}EventHandlers, { + async on${Svc}Event(event, ctx) { + // TODO: handle the ${Svc}Event event. + console.log(\`[${svcConst}] ${Svc}Event: \${event.id}\`); + await ctx.ack(); + }, + }); +}; +`; +} + +/** Build the file map `generate service` emits (relative paths). */ +export function buildServiceFiles(name: string, withEvents: boolean): Map { + const pkg = packageName(name); + const files = new Map([ + [`proto/${pkg}/v1/${pkg}.proto`, generateServiceProto(name, withEvents)], + [`src/services/${camelCase(name)}Service.ts`, generateServiceImpl(name, withEvents)], + ]); + if (withEvents) { + // The event proto imports the vendored option proto; emit it (skipped if present). + files.set("proto/connectum/events/v1/options.proto", generateEventsOptionsProto()); + } + return files; +} + +/** The manual registration edit to print (D-11 — never edits server.ts). */ +export function registrationMessage(name: string, withEvents: boolean): string { + const svcConst = `${camelCase(name)}Service`; + const genPath = `#services/${camelCase(name)}Service.ts`; + const lines = [ + "Register the new service in src/server.ts:", + ` import { ${svcConst} } from "${genPath}";`, + ` // add ${svcConst} to the services: [...] array passed to createServer`, + ]; + if (withEvents) { + const routesConst = `${camelCase(name)}EventRoutes`; + lines.push( + "", + "For the event handler, register it with your EventBus (requires @connectum/events):", + ` import { ${routesConst} } from "${genPath}";`, + ` // add ${routesConst} to the EventBus routes: [...] array`, + ); + } + lines.push("", "Then run: buf generate (runs automatically on test/start)"); + return lines.join("\n"); +} diff --git a/packages/cli/src/scaffold/prompts.ts b/packages/cli/src/scaffold/prompts.ts new file mode 100644 index 00000000..5ed0d355 --- /dev/null +++ b/packages/cli/src/scaffold/prompts.ts @@ -0,0 +1,128 @@ +/** + * Interactive wizard for `connectum init` (task 2.1). Collects any answers not + * supplied as flags via `@clack/prompts`, and collapses to the same `RawInput` the + * non-interactive flag path produces (D-5). + * + * The prompt calls go through a small {@link Prompter} seam so the wizard logic can + * be unit-tested with a stub — `@clack/prompts` is loaded lazily and never imported + * in tests (which also sidesteps clack's `bun:test` module-mock limitation, D-5). + * + * @module scaffold/prompts + */ + +import type { RawInput } from "./config.ts"; + +/** Minimal prompt surface; the clack implementation handles cancellation (exit). */ +export interface Prompter { + text(opts: { message: string; placeholder?: string; validate?: (v: string | undefined) => string | undefined }): Promise; + select(opts: { message: string; options: { value: T; label?: string }[]; initialValue?: T }): Promise; + confirm(opts: { message: string; initialValue?: boolean }): Promise; +} + +/** True when the wizard must be skipped (explicit `--yes`, CI, or a non-TTY stdout). */ +export function isNonInteractive(flags: { yes?: boolean | undefined }): boolean { + return flags.yes === true || process.env.CI === "true" || !process.stdout.isTTY; +} + +/** Default {@link Prompter} backed by `@clack/prompts` (loaded lazily). */ +export const clackPrompter: Prompter = { + async text(opts) { + const p = await import("@clack/prompts"); + const v = await p.text(opts); + if (p.isCancel(v)) { + p.cancel("Aborted."); + process.exit(1); + } + return v; + }, + async select(opts) { + const p = await import("@clack/prompts"); + const v = await p.select(opts as never); + if (p.isCancel(v)) { + p.cancel("Aborted."); + process.exit(1); + } + return v as never; + }, + async confirm(opts) { + const p = await import("@clack/prompts"); + const v = await p.confirm(opts); + if (p.isCancel(v)) { + p.cancel("Aborted."); + process.exit(1); + } + return v; + }, +}; + +const RUNTIME_OPTIONS = [ + { value: "node" as const, label: "Node.js" }, + { value: "bun" as const, label: "Bun" }, +]; +const NODE_EXEC_OPTIONS = [ + { value: "raw" as const, label: "raw .ts (Node >=25.2)" }, + { value: "tsx" as const, label: "tsx (Node >=22.13)" }, +]; +const PM_OPTIONS = [ + { value: "pnpm" as const, label: "pnpm" }, + { value: "npm" as const, label: "npm" }, +]; +const ADAPTER_OPTIONS = [{ value: "nats" as const }, { value: "kafka" as const }, { value: "redpanda" as const }, { value: "redis" as const }, { value: "amqp" as const }]; + +/** + * Collect a full {@link RawInput}: in non-interactive mode return the flags as-is + * (validation/defaults happen in `resolveConfig`); otherwise prompt for anything the + * flags did not already provide. + */ +export async function collectConfig(flags: RawInput & { yes?: boolean | undefined }, prompter: Prompter = clackPrompter): Promise { + if (isNonInteractive(flags)) { + return flags; + } + return promptForMissing(flags, prompter); +} + +/** + * Prompt (via `prompter`) for every field the flags did not already provide. Always + * interactive — {@link collectConfig} owns the non-interactive short-circuit. + */ +export async function promptForMissing(flags: RawInput, prompter: Prompter): Promise { + const name = + flags.name ?? + (await prompter.text({ + message: "Project name", + placeholder: "my-service", + validate: (v) => ((v ?? "").trim() === "" ? "A project name is required" : undefined), + })); + const runtime = flags.runtime ?? (await prompter.select({ message: "Runtime", options: RUNTIME_OPTIONS, initialValue: "node" })); + const nodeExec = + runtime === "node" ? (flags.nodeExec ?? (await prompter.select({ message: "Node execution model", options: NODE_EXEC_OPTIONS, initialValue: "raw" }))) : flags.nodeExec; + const packageManager = flags.packageManager ?? (await prompter.select({ message: "Package manager", options: PM_OPTIONS, initialValue: "pnpm" })); + const otel = flags.otel ?? (await prompter.confirm({ message: "Add OpenTelemetry?", initialValue: false })); + const auth = flags.auth ?? (await prompter.confirm({ message: "Add auth (JWT + proto authorization)?", initialValue: false })); + + let events = flags.events; + if (events === undefined || events.trim() === "") { + // `--events` with no value is an explicit opt-in that named no adapter: skip the + // yes/no question and go straight to the adapter picker. + const wantEvents = events !== undefined || (await prompter.confirm({ message: "Add an EventBus?", initialValue: false })); + events = wantEvents ? await prompter.select({ message: "Event adapter", options: ADAPTER_OPTIONS, initialValue: "nats" }) : undefined; + } + + const sample = flags.sample ?? (await prompter.confirm({ message: "Include the sample Greeter service?", initialValue: true })); + + // Advanced toggles are flag-only (kept out of the wizard); preserve them. + return { + name, + runtime, + packageManager, + nodeExec, + sample, + otel, + auth, + events, + resilience: flags.resilience, + healthcheck: flags.healthcheck, + reflection: flags.reflection, + catalog: flags.catalog, + }; +} diff --git a/packages/cli/src/scaffold/serverGen.ts b/packages/cli/src/scaffold/serverGen.ts new file mode 100644 index 00000000..7046aece --- /dev/null +++ b/packages/cli/src/scaffold/serverGen.ts @@ -0,0 +1,159 @@ +/** + * Generate the project's composition root (`src/server.ts`) and entry (`src/index.ts`) + * from the resolved module set. + * + * This is where module wiring is composed — most importantly the **interceptor order** + * (OpenSpec change cli-scaffolding, D-3, ratified otel-outermost): a single total order + * across any module subset, not ad-hoc concatenation. For the base (no modules) the + * output is functionally identical to the dogfooded `getting-started` server. + * + * @module scaffold/serverGen + */ + +import type { ScaffoldConfig } from "./types.ts"; + +/** Render the `createDefaultInterceptors(...)` call with opt-in resilience + errorHandler flag. */ +function defaultsCall(config: ScaffoldConfig, errorHandlerFalse: boolean): string { + const parts: string[] = []; + if (errorHandlerFalse) { + parts.push("errorHandler: false"); + } + for (const r of config.modules.resilience ?? []) { + parts.push(`${r}: true`); + } + return parts.length > 0 ? `createDefaultInterceptors({ ${parts.join(", ")} })` : "createDefaultInterceptors()"; +} + +/** + * Build the `interceptors:` expression + imports, applying the canonical order (D-3): + * otel -> errorHandler -> auth -> validation. `createDefaultInterceptors()` already + * yields `errorHandler -> validation` (+ opt-in resilience). + */ +function interceptorsExpr(config: ScaffoldConfig): { imports: string[]; expr: string } { + const otel = config.modules.otel === true; + const auth = config.modules.auth === true; + const imports: string[] = []; + const parts: string[] = []; + + if (otel) { + imports.push('import { createOtelInterceptor } from "@connectum/otel";'); + parts.push("createOtelInterceptor({ trustRemote: true })"); + } + + if (auth) { + imports.push('import { createDefaultInterceptors, createErrorHandlerInterceptor } from "@connectum/interceptors";'); + imports.push('import { buildAuthInterceptors } from "#auth.ts";'); + parts.push("createErrorHandlerInterceptor()", "...buildAuthInterceptors()", `...${defaultsCall(config, true)}`); + return { imports, expr: `[${parts.join(", ")}]` }; + } + + imports.push('import { createDefaultInterceptors } from "@connectum/interceptors";'); + if (parts.length > 0) { + parts.push(`...${defaultsCall(config, false)}`); + return { imports, expr: `[${parts.join(", ")}]` }; + } + return { imports, expr: defaultsCall(config, false) }; +} + +/** Build the `protocols:` expression + imports from the healthcheck/reflection toggles. */ +function protocolsExpr(config: ScaffoldConfig): { imports: string[]; expr: string } { + const imports: string[] = []; + const parts: string[] = []; + if (config.modules.healthcheck !== false) { + imports.push('import { Healthcheck } from "@connectum/healthcheck";'); + parts.push("Healthcheck({ httpEnabled: true })"); + } + if (config.modules.reflection !== false) { + imports.push('import { Reflection } from "@connectum/reflection";'); + parts.push("Reflection()"); + } + return { imports, expr: `[${parts.join(", ")}]` }; +} + +/** + * Generate `src/server.ts`. + */ +export function generateServer(config: ScaffoldConfig): string { + const { imports: interceptorImports, expr } = interceptorsExpr(config); + const { imports: protocolImports, expr: protoExpr } = protocolsExpr(config); + const events = config.modules.events !== undefined; + const catalog = config.modules.catalog === true; + const imports = [ + 'import { createServer } from "@connectum/core";', + 'import type { Server } from "@connectum/core";', + ...protocolImports, + ...interceptorImports, + ...(catalog ? ['import { serviceCatalog } from "#gen/catalog.gen.ts";'] : []), + ...(events ? ['import { greeterEventBus } from "#greeterEventBus.ts";'] : []), + 'import { greeterService } from "#services/greeterService.ts";', + ]; + const eventBusLine = events ? "\n eventBus: greeterEventBus," : ""; + const catalogLine = catalog ? "\n catalog: serviceCatalog," : ""; + return `/** + * Server factory — services, health, reflection, interceptors, graceful shutdown. + * + * @module server + */ + +${imports.join("\n")} + +/** + * Build a Connectum server hosting GreeterService. + * + * @param port - TCP port to bind (0 = random, for tests). + * @param autoShutdown - install SIGTERM/SIGINT graceful-shutdown handlers. + */ +export function buildServer(port = 5000, autoShutdown = false): Server { + return createServer({ + services: [greeterService],${catalogLine}${eventBusLine} + port, + host: "0.0.0.0", + allowHTTP1: false, + protocols: ${protoExpr}, + interceptors: ${expr}, + shutdown: { autoShutdown, timeout: 10_000 }, + }); +} +`; +} + +/** + * Generate `src/index.ts` (process entry). + */ +export function generateIndex(config: ScaffoldConfig): string { + const otel = config.modules.otel === true; + const healthcheck = config.modules.healthcheck !== false; + const imports = [ + ...(healthcheck ? ['import { healthcheckManager, ServingStatus } from "@connectum/healthcheck";'] : []), + ...(otel ? ['import { initProvider, shutdownProvider } from "@connectum/otel";'] : []), + 'import { buildServer } from "#server.ts";', + ]; + const initBlock = otel ? `\ninitProvider({ serviceName: ${JSON.stringify(config.name)} });\n` : ""; + const readyLifecycle = healthcheck ? " healthcheckManager.update(ServingStatus.SERVING);\n" : ""; + const stopHandler = otel + ? `server.on("stop", async () => {\n await shutdownProvider();\n console.log("stopped");\n});` + : `server.on("stop", () => console.log("stopped"));`; + return `/** + * Start the server. + * + * @module index + */ + +${imports.join("\n")} +${initBlock} +const server = buildServer(Number(process.env.PORT ?? 5000), true); + +server.on("ready", () => { + const addr = server.address; +${readyLifecycle} console.log(\`${config.name} ready on \${addr?.address}:\${addr?.port}\`); +}); + +${stopHandler} +server.on("error", (err) => { + console.error("server error:", err); + process.exitCode = 1; +}); + +await server.start(); +`; +} diff --git a/packages/cli/src/scaffold/transform.ts b/packages/cli/src/scaffold/transform.ts new file mode 100644 index 00000000..3d20839c --- /dev/null +++ b/packages/cli/src/scaffold/transform.ts @@ -0,0 +1,301 @@ +/** + * Pure base transform for `connectum init`. + * + * Takes the file map of the fetched `getting-started` base plus the resolved + * {@link ScaffoldConfig} and produces the final project file map. Pure (no I/O) so + * it is fully unit-testable against a local fixture without any network. + * + * Base-level responsibilities (OpenSpec change cli-scaffolding, tasks 1.1/1.2/1.5/1.6): + * - de-monorepo (drop `pnpm-workspace.yaml` / `.pnpmfile.cjs`); + * - rewrite `package.json` (name, engines, scripts + lifecycle-fix, devDeps) for the + * chosen runtime / package-manager / node-exec model; + * - regenerate the e2e test to use the public in-process `createLocalClient` (D-7). + * + * Module composition (events/otel/auth/...) layers on top of this in Phase 2. + * + * @module scaffold/transform + */ + +import { generateAuthFile } from "./authFragment.ts"; +import { generateBufGenYaml, generateBufYaml } from "./bufConfig.ts"; +import { adapterPackage, generateEventBusFile, generateEventRouteFile, generateEventsOptionsProto, generateEventsProto, generateEventsTest } from "./eventsFragment.ts"; +import { generateIndex, generateServer } from "./serverGen.ts"; +import type { NodeExec, PackageManager, Runtime, ScaffoldConfig } from "./types.ts"; +import { nodeEngineFloor } from "./types.ts"; + +/** Files that are monorepo-only plumbing and never belong in a standalone project. */ +const MONOREPO_ONLY = new Set(["pnpm-workspace.yaml", ".pnpmfile.cjs"]); + +/** Path of the base sample's e2e test, regenerated by this transform. */ +const E2E_TEST_PATH = "tests/e2e/e2e.test.ts"; + +/** + * The scaffolded project's `scripts` block. A concrete type (not a `Record`) so the + * absence of pnpm `pre*` hooks is structural — the lifecycle-fix lives inside + * `typecheck`/`test`/`start`, not in a hook that silently no-ops. + */ +export interface ProjectScripts { + "buf:generate": string; + "buf:lint": string; + "build:proto": string; + typecheck: string; + start: string; + dev: string; + test: string; +} + +/** + * Build the runtime/PM/node-exec-specific `scripts` block, with the lifecycle-fix + * (`buf generate &&` prefixed onto `typecheck`/`test`/`start`) baked in and the + * codegen alias kept package-manager-independent (`buf generate`, never `pnpm run …`). + */ +export function buildScripts(config: ScaffoldConfig): ProjectScripts { + const common = { + "buf:generate": "buf generate", + "buf:lint": "buf lint", + "build:proto": "buf generate", + typecheck: "buf generate && tsc --noEmit", + }; + + if (config.runtime === "bun") { + return { + ...common, + start: "buf generate && bun src/index.ts", + dev: "bun --watch src/index.ts", + test: "buf generate && bun test tests/", + }; + } + + // Node + const start = config.nodeExec === "tsx" ? "buf generate && tsx src/index.ts" : "buf generate && node src/index.ts"; + const dev = config.nodeExec === "tsx" ? "tsx watch src/index.ts" : "node --watch src/index.ts"; + // Under the tsx model (Node 22) `node --test` cannot strip `.ts`, so load tsx. + const test = config.nodeExec === "tsx" ? "buf generate && node --import tsx --test tests/**/*.test.ts" : "buf generate && node --test tests/**/*.test.ts"; + return { ...common, start, dev, test }; +} + +/** + * Build the `devDependencies` block for the scaffolded project. + * + * Keeps the codegen toolchain (`@bufbuild/buf`, `@bufbuild/protoc-gen-es`, + * `typescript`, `@types/node`), adds `@connectum/testing` (for the in-process + * `createLocalClient` e2e test, D-7), adds `tsx` only under the Node tsx model, and + * drops `@connectrpc/connect-node` (unused now that the e2e test is in-process). + */ +export function buildDevDeps(existing: Record, config: ScaffoldConfig, connectumVersion: string): Record { + const keep = ["@bufbuild/buf", "@bufbuild/protoc-gen-es", "@types/node", "typescript"]; + const devDeps: Record = {}; + for (const key of keep) { + if (existing[key]) { + devDeps[key] = existing[key]; + } + } + devDeps["@connectum/testing"] = connectumVersion; + if (config.modules.catalog) { + devDeps["@connectum/protoc-gen-catalog"] = connectumVersion; + } + if (config.runtime === "node" && config.nodeExec === "tsx") { + devDeps.tsx = existing.tsx ?? "^4.21.0"; + } + // Sort keys for a stable, diff-friendly package.json. + return Object.fromEntries(Object.entries(devDeps).sort(([a], [b]) => (a < b ? -1 : 1))); +} + +/** + * Rewrite the base `package.json` for the target project. + */ +export function transformPackageJson(raw: string, config: ScaffoldConfig): string { + const pkg = JSON.parse(raw) as Record & { + dependencies?: Record; + devDependencies?: Record; + }; + + pkg.name = config.name; + delete pkg.private; + pkg.description = `A Connectum gRPC/ConnectRPC service (${config.name})`; + pkg.engines = config.runtime === "bun" ? { node: ">=22.13.0" } : { node: nodeEngineFloor(config.nodeExec) }; + + // Version to pin @connectum/testing to: match the slice already used by the base + // (keep the whole @connectum/* stack on one minor — see reference_examples_connectum_slice). + const connectumVersion = pkg.dependencies?.["@connectum/core"] ?? "^1.0.0"; + + // Additive module runtime deps (kept on the same @connectum slice, then sorted). + const extraDeps: Record = {}; + if (config.modules.otel) { + extraDeps["@connectum/otel"] = connectumVersion; + } + if (config.modules.events) { + extraDeps["@connectum/events"] = connectumVersion; + extraDeps[adapterPackage(config.modules.events.adapter)] = connectumVersion; + } + if (config.modules.auth) { + extraDeps["@connectum/auth"] = connectumVersion; + } + // Note: no `&& pkg.dependencies` guard — a base without a `dependencies` block must + // still receive the enabled modules' deps rather than silently dropping them. + if (Object.keys(extraDeps).length > 0) { + pkg.dependencies = Object.fromEntries(Object.entries({ ...(pkg.dependencies ?? {}), ...extraDeps }).sort(([a], [b]) => (a < b ? -1 : 1))); + } + + pkg.scripts = buildScripts(config); + pkg.devDependencies = buildDevDeps(pkg.devDependencies ?? {}, config, connectumVersion); + + return `${JSON.stringify(pkg, null, 2)}\n`; +} + +/** + * Generate the in-process e2e test for the sample Greeter service (D-7). The test + * body is identical across runtimes; only the test-runner import differs + * (`node:test` on Node, `bun:test` on Bun, matching the chosen `test` script). + */ +export function generateGreeterE2eTest(runtime: Runtime): string { + const runnerImport = runtime === "bun" ? 'import { describe, it } from "bun:test";' : 'import { describe, it } from "node:test";'; + return `/** + * End-to-end test for the sample Greeter service. + * + * Uses @connectum/testing's in-process \`createLocalClient\` — no socket is opened, + * so the same test runs identically on Node and Bun. + */ + +import assert from "node:assert/strict"; +${runnerImport} +import { createServer } from "@connectum/core"; +import { createLocalClient } from "@connectum/testing"; +import { GreeterService } from "#gen/greeter/v1/greeter_pb.ts"; +import { greeterService } from "#services/greeterService.ts"; + +describe("GreeterService", () => { + const server = createServer({ services: [greeterService] }); + const client = createLocalClient(server, GreeterService); + + it("sayHello returns a greeting", async () => { + const res = await client.sayHello({ name: "Ada" }); + assert.equal(res.message, "Hello, Ada!"); + }); + + it("sayGoodbye returns a farewell", async () => { + const res = await client.sayGoodbye({ name: "Ada" }); + assert.equal(res.message, "Goodbye, Ada!"); + }); +}); +`; +} + +/** + * Transform the fetched base file map into the final project file map. + * + * @param files - The fetched `getting-started` base (relative path -> content) + * @param config - Resolved scaffolding configuration + * @returns The final project file map, ready for {@link emitFiles} + */ +export function transformBase(files: ReadonlyMap, config: ScaffoldConfig): Map { + const out = new Map(); + + for (const [relPath, content] of files) { + if (MONOREPO_ONLY.has(relPath)) { + continue; + } + if (relPath === "README.md") { + // README is regenerated below (project-specific). + continue; + } + if (relPath === E2E_TEST_PATH) { + // Regenerated with the in-process createLocalClient (D-7). + continue; + } + if (relPath === "src/server.ts" || relPath === "src/index.ts") { + // Regenerated as the composition root from the module set (D-2/D-3). + continue; + } + if (relPath === "buf.yaml" || relPath === "buf.gen.yaml") { + // Regenerated: buf.yaml (events/auth lint + modules), buf.gen.yaml (catalog plugin). + continue; + } + if (relPath === "package.json") { + out.set(relPath, transformPackageJson(content, config)); + continue; + } + out.set(relPath, content); + } + + out.set("src/server.ts", generateServer(config)); + out.set("src/index.ts", generateIndex(config)); + out.set("buf.yaml", generateBufYaml(config)); + out.set("buf.gen.yaml", generateBufGenYaml(config)); + out.set(E2E_TEST_PATH, generateGreeterE2eTest(config.runtime)); + out.set("README.md", generateReadme(config)); + + // auth module: JWT + proto-authz interceptor builder (buf.yaml adds the 2nd module). + if (config.modules.auth) { + out.set("src/auth.ts", generateAuthFile()); + } + + // events module: vendored option proto, demo event-handler proto, EventBus + route. + if (config.modules.events) { + out.set("proto/connectum/events/v1/options.proto", generateEventsOptionsProto()); + out.set("proto/greeter/v1/events.proto", generateEventsProto()); + out.set("src/greeterEventBus.ts", generateEventBusFile(config, config.modules.events.adapter)); + out.set("src/services/greeterEvents.ts", generateEventRouteFile()); + out.set("tests/e2e/events.test.ts", generateEventsTest(config.runtime)); + } + + // A standalone pnpm project must approve @bufbuild/buf's postinstall (the buf binary + // download) or `buf generate` cannot run (ERR_PNPM_IGNORED_BUILDS). Under pnpm 11 + // the setting lives in `pnpm-workspace.yaml` — and the key is **`allowBuilds`** (a + // map), which is what the monorepo root and every dogfooded example use. (The + // `onlyBuiltDependencies` list named in pnpm's deprecation warning for the old + // package.json `pnpm.*` field is NOT honoured by pnpm 11.0.4 — verified by CI.) + // npm runs postinstall by default, so no file is needed there. + if (config.packageManager === "pnpm") { + out.set("pnpm-workspace.yaml", "allowBuilds:\n '@bufbuild/buf': true\n esbuild: true\n"); + } + + return out; +} + +/** + * Generate a short project README with the correct run commands for the chosen + * runtime and package manager. + */ +export function generateReadme(config: ScaffoldConfig): string { + const pm = config.packageManager; + const run = (script: string): string => `${pm} run ${script}`; + const runtimeNote = + config.runtime === "bun" + ? "Runs on **Bun**." + : config.nodeExec === "tsx" + ? "Runs on **Node.js >= 22.13.0** via `tsx`." + : "Runs on **Node.js >= 25.2.0** (native TypeScript execution)."; + return `# ${config.name} + +A Connectum gRPC/ConnectRPC service scaffolded with \`connectum init\`. + +${runtimeNote} + +## Getting started + +\`\`\`bash +${pm} install +${run("start")} +\`\`\` + +\`buf generate\` runs automatically before \`start\`, \`test\`, and \`typecheck\`, so the +generated code under \`gen/\` is always up to date. + +## Commands + +- \`${run("start")}\` — start the server +- \`${run("dev")}\` — start in watch mode +- \`${run("test")}\` — run tests +- \`${run("typecheck")}\` — type-check +- \`${run("buf:lint")}\` — lint proto files + +## Next steps + +Replace the sample \`Greeter\` service under \`proto/\` and \`src/services/\` with your +own contract, then run \`buf generate\`. +`; +} + +// Re-export the config value types used by transform consumers. +export type { NodeExec, PackageManager, Runtime, ScaffoldConfig }; diff --git a/packages/cli/src/scaffold/types.ts b/packages/cli/src/scaffold/types.ts new file mode 100644 index 00000000..3002cad2 --- /dev/null +++ b/packages/cli/src/scaffold/types.ts @@ -0,0 +1,74 @@ +/** + * Shared types for the `connectum init` scaffolding pipeline. + * + * @module scaffold/types + */ + +/** Target runtime for the scaffolded project. */ +export type Runtime = "node" | "bun"; + +/** Package manager the scaffolded project is configured for. */ +export type PackageManager = "pnpm" | "npm"; + +/** + * Node execution model (only meaningful when {@link Runtime} is `node`): + * - `raw`: run `.ts` sources directly via native type-stripping (Node >= 25.2.0); + * - `tsx`: run via `tsx` (Node >= 22.13.0). + */ +export type NodeExec = "raw" | "tsx"; + +/** EventBus transport adapter. */ +export type EventAdapter = "nats" | "kafka" | "redpanda" | "redis" | "amqp"; + +/** Opt-in resilience interceptors (off by default in `createDefaultInterceptors`). */ +export type ResilienceInterceptor = "timeout" | "bulkhead" | "circuitBreaker" | "retry" | "fallback"; + +/** + * Optional modules the user can enable. Each maps to an additive {@link ScaffoldConfig} + * fragment (deps + wiring). Healthcheck and Reflection are part of the base and are + * not toggled here. + */ +export interface ModuleSelection { + /** OpenTelemetry: adds `createOtelInterceptor` (outermost) + provider lifecycle. */ + otel?: boolean; + /** EventBus: adds `@connectum/events` + the chosen adapter, an EventRoute, and proto. */ + events?: { adapter: EventAdapter } | undefined; + /** Auth: adds `@connectum/auth` (JWT + proto-driven authorization) + a second buf module. */ + auth?: boolean; + /** Opt-in resilience interceptors to enable in `createDefaultInterceptors`. */ + resilience?: readonly ResilienceInterceptor[]; + /** Include the gRPC health protocol (+ HTTP /healthz). Default true. */ + healthcheck?: boolean; + /** Include the gRPC server reflection protocol. Default true. */ + reflection?: boolean; + /** Service catalog: adds the catalog buf plugin + typed `ctx.call`/`ctx.stream`. */ + catalog?: boolean; +} + +/** + * Fully-resolved scaffolding configuration (the flat object both the interactive + * TUI and the non-interactive flag path collapse to). + */ +export interface ScaffoldConfig { + /** Project name / target directory. */ + name: string; + /** Target runtime. */ + runtime: Runtime; + /** Package manager. */ + packageManager: PackageManager; + /** Node execution model (ignored when `runtime` is `bun`). */ + nodeExec: NodeExec; + /** Emit the runnable sample service (`true`) or config-only (`false`). */ + sample: boolean; + /** Enabled optional modules. */ + modules: ModuleSelection; +} + +/** + * The Node floor implied by a Node execution model: + * - `raw` → 25.2.0 (native `.ts` execution); + * - `tsx` → 22.13.0 (compiled-consumer floor). + */ +export function nodeEngineFloor(nodeExec: NodeExec): string { + return nodeExec === "raw" ? ">=25.2.0" : ">=22.13.0"; +} diff --git a/packages/cli/src/tiged.d.ts b/packages/cli/src/tiged.d.ts new file mode 100644 index 00000000..afc623c7 --- /dev/null +++ b/packages/cli/src/tiged.d.ts @@ -0,0 +1,16 @@ +/** + * Minimal ambient type declarations for `tiged` (the maintained `degit` fork used + * by the `connectum init` base fetcher). `tiged` ships JavaScript without bundled + * `.d.ts`, so we declare only the tiny surface we use. + */ +declare module "tiged" { + interface TigedOptions { + cache?: boolean; + force?: boolean; + verbose?: boolean; + } + interface TigedEmitter { + clone(dest: string): Promise; + } + export default function tiged(src: string, opts?: TigedOptions): TigedEmitter; +} diff --git a/packages/cli/src/utils/emit.ts b/packages/cli/src/utils/emit.ts new file mode 100644 index 00000000..ce0a0d3e --- /dev/null +++ b/packages/cli/src/utils/emit.ts @@ -0,0 +1,90 @@ +/** + * File-emission layer for the scaffolding commands (`init`, `generate service`). + * + * Design contract (OpenSpec change cli-scaffolding, task 0.2): + * - **Path-safe**: every relative path is validated; emission cannot escape the + * target directory (rejects absolute paths — POSIX and Windows — and `..`). + * - **Refuse-to-clobber**: existing files are skipped unless `force` is set. + * - **Deterministic**: files are emitted in sorted-path order. + * + * @module utils/emit + */ + +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +/** Matches Windows absolute paths: drive-letter (`C:\`, `C:/`) and UNC (`\\server\share`). */ +const WINDOWS_ABSOLUTE = /^[A-Za-z]:[\\/]|^\\\\/; + +/** + * Assert that `relPath` is a safe relative path to emit under a target directory. + * + * Rejects empty paths, absolute paths (POSIX `/...` and Windows drive/UNC), and any + * path containing a parent-traversal (`..`) segment — mirroring the path-safety used + * by `@connectum/protoc-gen-catalog` so generated output cannot escape its root. + * + * @param relPath - Candidate relative path + * @throws Error if the path is unsafe + */ +export function assertSafeRelativePath(relPath: string): void { + if (relPath === "" || relPath.startsWith("/") || WINDOWS_ABSOLUTE.test(relPath) || relPath.split(/[/\\]/).includes("..")) { + throw new Error(`Unsafe emit path: "${relPath}" (must be a relative path without "..")`); + } +} + +/** + * Options for {@link emitFiles}. + */ +export interface EmitOptions { + /** Overwrite existing files instead of skipping them (refuse-to-clobber is the default). */ + force?: boolean; +} + +/** + * Result of {@link emitFiles}. + */ +export interface EmitResult { + /** Relative paths that were written. */ + written: string[]; + /** Relative paths skipped because a file already existed (and `force` was not set). */ + skipped: string[]; +} + +/** + * Emit a set of files under `targetDir`. + * + * All relative paths are validated up front (a single unsafe path aborts before any + * write). Files are emitted in sorted-path order; parent directories are created as + * needed. By default an existing file is skipped (refuse-to-clobber); pass + * `force: true` to overwrite. + * + * @param targetDir - Directory the relative paths are resolved against + * @param files - Map of relative path -> file content + * @param options - Emission options + * @returns Which paths were written and which were skipped + */ +export function emitFiles(targetDir: string, files: ReadonlyMap, options: EmitOptions = {}): EmitResult { + const entries = [...files.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + + // Validate every path before writing anything, so an unsafe path never leaves a + // partially-emitted tree behind. + for (const [relPath] of entries) { + assertSafeRelativePath(relPath); + } + + const written: string[] = []; + const skipped: string[] = []; + + for (const [relPath, content] of entries) { + const absPath = join(targetDir, relPath); + if (!options.force && existsSync(absPath)) { + skipped.push(relPath); + continue; + } + mkdirSync(dirname(absPath), { recursive: true }); + writeFileSync(absPath, content); + written.push(relPath); + } + + return { written, skipped }; +} diff --git a/packages/cli/tests/unit/emit.test.ts b/packages/cli/tests/unit/emit.test.ts new file mode 100644 index 00000000..8768220d --- /dev/null +++ b/packages/cli/tests/unit/emit.test.ts @@ -0,0 +1,117 @@ +/** + * Unit tests for the file-emission layer (utils/emit). + * + * Covers path-safety, refuse-to-clobber (default), force overwrite, deterministic + * ordering, and the all-or-nothing validation guarantee. + */ + +import assert from "node:assert"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, it } from "node:test"; +import { assertSafeRelativePath, emitFiles } from "../../src/utils/emit.ts"; + +describe("assertSafeRelativePath", () => { + it("accepts a normal nested relative path", () => { + assert.doesNotThrow(() => assertSafeRelativePath("src/services/foo.ts")); + }); + + it("rejects an empty path", () => { + assert.throws(() => assertSafeRelativePath(""), /Unsafe emit path/); + }); + + it("rejects a POSIX absolute path", () => { + assert.throws(() => assertSafeRelativePath("/etc/passwd"), /Unsafe emit path/); + }); + + it("rejects a Windows drive-absolute path", () => { + assert.throws(() => assertSafeRelativePath("C:\\Windows\\x"), /Unsafe emit path/); + }); + + it("rejects a UNC path", () => { + assert.throws(() => assertSafeRelativePath("\\\\server\\share"), /Unsafe emit path/); + }); + + it("rejects parent traversal in any position", () => { + assert.throws(() => assertSafeRelativePath("../escape.ts"), /Unsafe emit path/); + assert.throws(() => assertSafeRelativePath("a/../../b"), /Unsafe emit path/); + assert.throws(() => assertSafeRelativePath("a\\..\\b"), /Unsafe emit path/); + }); +}); + +describe("emitFiles", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "connectum-emit-test-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("writes files and creates parent directories", () => { + const result = emitFiles( + dir, + new Map([ + ["package.json", "{}\n"], + ["src/index.ts", "export {};\n"], + ]), + ); + assert.deepStrictEqual(result.written, ["package.json", "src/index.ts"]); + assert.deepStrictEqual(result.skipped, []); + assert.strictEqual(readFileSync(join(dir, "package.json"), "utf8"), "{}\n"); + assert.strictEqual(readFileSync(join(dir, "src/index.ts"), "utf8"), "export {};\n"); + }); + + it("refuses to clobber an existing file by default", () => { + mkdirSync(join(dir, "src"), { recursive: true }); + writeFileSync(join(dir, "src/index.ts"), "ORIGINAL"); + const result = emitFiles( + dir, + new Map([ + ["src/index.ts", "NEW"], + ["src/new.ts", "NEW2"], + ]), + ); + assert.deepStrictEqual(result.skipped, ["src/index.ts"]); + assert.deepStrictEqual(result.written, ["src/new.ts"]); + assert.strictEqual(readFileSync(join(dir, "src/index.ts"), "utf8"), "ORIGINAL"); + }); + + it("overwrites when force is set", () => { + writeFileSync(join(dir, "a.txt"), "OLD"); + const result = emitFiles(dir, new Map([["a.txt", "NEW"]]), { force: true }); + assert.deepStrictEqual(result.written, ["a.txt"]); + assert.strictEqual(readFileSync(join(dir, "a.txt"), "utf8"), "NEW"); + }); + + it("emits in deterministic sorted order", () => { + const result = emitFiles( + dir, + new Map([ + ["z.txt", "z"], + ["a.txt", "a"], + ["m.txt", "m"], + ]), + ); + assert.deepStrictEqual(result.written, ["a.txt", "m.txt", "z.txt"]); + }); + + it("aborts before writing anything if any path is unsafe", () => { + assert.throws( + () => + emitFiles( + dir, + new Map([ + ["ok.txt", "ok"], + ["../escape.txt", "bad"], + ]), + ), + /Unsafe emit path/, + ); + // "ok.txt" must NOT have been written — validation runs before any write. + assert.throws(() => readFileSync(join(dir, "ok.txt"), "utf8")); + }); +}); diff --git a/packages/cli/tests/unit/scaffold-auth.test.ts b/packages/cli/tests/unit/scaffold-auth.test.ts new file mode 100644 index 00000000..af1c0e0e --- /dev/null +++ b/packages/cli/tests/unit/scaffold-auth.test.ts @@ -0,0 +1,75 @@ +/** + * Unit tests for the auth module fragment (task 2.4): auth.ts generation, the + * interceptor-ordering auth branch (D-3), the second buf module, and composition. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { generateAuthFile } from "../../src/scaffold/authFragment.ts"; +import { generateBufYaml } from "../../src/scaffold/bufConfig.ts"; +import { resolveConfig } from "../../src/scaffold/config.ts"; +import { generateServer } from "../../src/scaffold/serverGen.ts"; +import { transformBase } from "../../src/scaffold/transform.ts"; +import type { ScaffoldConfig } from "../../src/scaffold/types.ts"; + +const authConfig: ScaffoldConfig = { name: "acct", runtime: "node", packageManager: "pnpm", nodeExec: "raw", sample: true, modules: { auth: true } }; + +describe("resolveConfig auth flag", () => { + it("enables auth", () => { + assert.equal(resolveConfig({ name: "x", auth: true }).modules.auth, true); + assert.equal(resolveConfig({ name: "x" }).modules.auth, false); + }); +}); + +describe("generateAuthFile", () => { + it("wires the JWT + proto-authz interceptors", () => { + const f = generateAuthFile(); + assert.match(f, /import \{ createJwtAuthInterceptor \} from "@connectum\/auth"/); + assert.match(f, /import \{ createProtoAuthzInterceptor, getPublicMethods \} from "@connectum\/auth\/proto"/); + assert.match(f, /export function buildAuthInterceptors\(\): Interceptor\[\]/); + assert.match(f, /getPublicMethods\(\[GreeterService\]\)/); + }); +}); + +describe("generateServer with auth (D-3 ordering)", () => { + it("places errorHandler then auth then default chain without a duplicate errorHandler", () => { + const s = generateServer(authConfig); + assert.match(s, /import \{ buildAuthInterceptors \} from "#auth\.ts"/); + assert.match(s, /import \{ createDefaultInterceptors, createErrorHandlerInterceptor \} from "@connectum\/interceptors"/); + assert.match(s, /\[createErrorHandlerInterceptor\(\), \.\.\.buildAuthInterceptors\(\), \.\.\.createDefaultInterceptors\(\{ errorHandler: false \}\)\]/); + }); + + it("keeps otel outermost when otel + auth are both enabled", () => { + const s = generateServer({ ...authConfig, modules: { auth: true, otel: true } }); + const arr = s.slice(s.indexOf("interceptors: [")); + assert.ok(arr.indexOf("createOtelInterceptor") < arr.indexOf("createErrorHandlerInterceptor")); + assert.ok(arr.indexOf("createErrorHandlerInterceptor") < arr.indexOf("buildAuthInterceptors")); + }); +}); + +describe("generateBufYaml with auth", () => { + it("adds the node_modules auth module and RPC_* excepts", () => { + const y = generateBufYaml(authConfig); + assert.match(y, /- path: node_modules\/@connectum\/auth\/proto/); + assert.match(y, /RPC_REQUEST_STANDARD_NAME/); + }); + it("has no auth module when auth is disabled", () => { + assert.doesNotMatch(generateBufYaml({ ...authConfig, modules: {} }), /node_modules/); + }); +}); + +describe("transformBase with auth", () => { + const base = new Map([ + ["package.json", JSON.stringify({ name: "@connectum/example-getting-started", dependencies: { "@connectum/core": "^1.2.0" }, devDependencies: {} })], + ["buf.yaml", "version: v2\n"], + ["src/services/greeterService.ts", "export const greeterService = {};\n"], + ]); + + it("emits src/auth.ts and adds @connectum/auth, buf module", () => { + const out = transformBase(base, authConfig); + assert.ok(out.has("src/auth.ts")); + const pkg = JSON.parse(out.get("package.json") ?? "{}"); + assert.equal(pkg.dependencies["@connectum/auth"], "^1.2.0"); + assert.match(out.get("buf.yaml") ?? "", /node_modules\/@connectum\/auth\/proto/); + }); +}); diff --git a/packages/cli/tests/unit/scaffold-catalog.test.ts b/packages/cli/tests/unit/scaffold-catalog.test.ts new file mode 100644 index 00000000..8579c60d --- /dev/null +++ b/packages/cli/tests/unit/scaffold-catalog.test.ts @@ -0,0 +1,48 @@ +/** + * Unit tests for the catalog / multi-service module fragment (task 2.7). + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { generateBufGenYaml } from "../../src/scaffold/bufConfig.ts"; +import { generateServer } from "../../src/scaffold/serverGen.ts"; +import { transformBase } from "../../src/scaffold/transform.ts"; +import type { ScaffoldConfig } from "../../src/scaffold/types.ts"; + +const catalogConfig: ScaffoldConfig = { name: "svc", runtime: "node", packageManager: "pnpm", nodeExec: "raw", sample: true, modules: { catalog: true } }; + +describe("generateBufGenYaml", () => { + it("adds the catalog plugin with strategy: all when enabled", () => { + const y = generateBufGenYaml(catalogConfig); + assert.match(y, /protoc-gen-connectum-catalog/); + assert.match(y, /strategy: all/); + }); + it("is es-only when catalog is disabled", () => { + const y = generateBufGenYaml({ ...catalogConfig, modules: {} }); + assert.match(y, /protoc-gen-es/); + assert.doesNotMatch(y, /catalog/); + }); +}); + +describe("generateServer with catalog", () => { + it("imports serviceCatalog and passes catalog to createServer", () => { + const s = generateServer(catalogConfig); + assert.match(s, /import \{ serviceCatalog \} from "#gen\/catalog\.gen\.ts"/); + assert.match(s, /catalog: serviceCatalog,/); + }); +}); + +describe("transformBase with catalog", () => { + const base = new Map([ + ["package.json", JSON.stringify({ name: "@connectum/example-getting-started", dependencies: { "@connectum/core": "^1.2.0" }, devDependencies: {} })], + ["buf.gen.yaml", "version: v2\n"], + ["src/services/greeterService.ts", "export const greeterService = {};\n"], + ]); + + it("regenerates buf.gen.yaml with the catalog plugin and adds the dev dep", () => { + const out = transformBase(base, catalogConfig); + assert.match(out.get("buf.gen.yaml") ?? "", /protoc-gen-connectum-catalog/); + const pkg = JSON.parse(out.get("package.json") ?? "{}"); + assert.equal(pkg.devDependencies["@connectum/protoc-gen-catalog"], "^1.2.0"); + }); +}); diff --git a/packages/cli/tests/unit/scaffold-commands.test.ts b/packages/cli/tests/unit/scaffold-commands.test.ts new file mode 100644 index 00000000..60cd4efe --- /dev/null +++ b/packages/cli/tests/unit/scaffold-commands.test.ts @@ -0,0 +1,132 @@ +/** + * Tests for the scaffolding command seam. + * + * `init` runs the real fetch→transform→emit pipeline against an injected local + * clone stub (no network). `generate service` is still a Phase-3 work-in-progress + * stub and this locks its honest contract. + */ + +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, it } from "node:test"; +import { executeGenerateService, generateServiceCommand } from "../../src/commands/generate-service.ts"; +import { executeInit, initCommand } from "../../src/commands/init.ts"; +import type { CloneFn } from "../../src/scaffold/fetchBase.ts"; + +/** A clone stub that writes a minimal getting-started-shaped base into `dest`. */ +const cloneStub: CloneFn = async (_source, dest) => { + mkdirSync(join(dest, "src/services"), { recursive: true }); + mkdirSync(join(dest, "tests/e2e"), { recursive: true }); + writeFileSync( + join(dest, "package.json"), + JSON.stringify( + { + name: "@connectum/example-getting-started", + private: true, + type: "module", + imports: { "#gen/*": "./gen/*", "#*": "./src/*" }, + scripts: { start: "node src/index.ts" }, + dependencies: { "@connectum/core": "^1.0.0" }, + devDependencies: { "@bufbuild/buf": "^1.65.0", "@connectrpc/connect-node": "^2.1.1", typescript: "^5.9.3" }, + engines: { node: ">=25.2.0" }, + }, + null, + 2, + ), + ); + writeFileSync(join(dest, "pnpm-workspace.yaml"), "packages: []\n"); + writeFileSync(join(dest, "src/services/greeterService.ts"), "export const greeterService = {};\n"); + writeFileSync(join(dest, "tests/e2e/e2e.test.ts"), "// old test using createGrpcTransport\n"); + writeFileSync(join(dest, "buf.gen.yaml"), "version: v2\n"); +}; + +describe("init command", () => { + it("exports a citty command with a run handler", () => { + assert.equal(typeof initCommand.run, "function"); + }); +}); + +describe("executeInit pipeline (injected clone, no network)", () => { + let workdir: string; + let cwd: string; + + beforeEach(() => { + cwd = process.cwd(); + workdir = mkdtempSync(join(tmpdir(), "connectum-init-work-")); + process.chdir(workdir); + }); + + afterEach(() => { + process.chdir(cwd); + rmSync(workdir, { recursive: true, force: true }); + }); + + it("scaffolds a project from the base", async () => { + await executeInit({ name: "myservice", clone: cloneStub }); + const pkg = JSON.parse(readFileSync(join(workdir, "myservice/package.json"), "utf8")); + assert.equal(pkg.name, "myservice"); + assert.equal(pkg.private, undefined); + // pnpm (default) gets a standalone pnpm-workspace.yaml with the buf build-approval. + assert.match(readFileSync(join(workdir, "myservice/pnpm-workspace.yaml"), "utf8"), /allowBuilds:/); + assert.match(readFileSync(join(workdir, "myservice/tests/e2e/e2e.test.ts"), "utf8"), /createLocalClient/); + assert.equal(pkg.devDependencies["@connectum/testing"], "^1.0.0"); + }); + + it("refuses to clobber a non-empty target directory", async () => { + mkdirSync(join(workdir, "taken"), { recursive: true }); + writeFileSync(join(workdir, "taken/package.json"), "{}"); + await assert.rejects(() => executeInit({ name: "taken", clone: cloneStub }), /already exists and is not empty/); + }); + + it("rejects an invalid runtime before doing any work", async () => { + await assert.rejects(() => executeInit({ name: "x", runtime: "deno", clone: cloneStub }), /invalid --runtime/); + }); + + it("fails clearly when the target path exists as a file (not a raw ENOTDIR)", async () => { + writeFileSync(join(workdir, "afile"), "x"); + await assert.rejects(() => executeInit({ name: "afile", clone: cloneStub }), /is not a directory/); + }); +}); + +describe("executeGenerateService", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "connectum-gensvc-")); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("exports a citty command with a run handler", () => { + assert.equal(typeof generateServiceCommand.run, "function"); + }); + + it("scaffolds a proto + defineService skeleton (throwing Unimplemented, D-6)", async () => { + await executeGenerateService({ name: "billing", cwd: dir }); + assert.match(readFileSync(join(dir, "proto/billing/v1/billing.proto"), "utf8"), /service BillingService/); + const impl = readFileSync(join(dir, "src/services/billingService.ts"), "utf8"); + assert.match(impl, /defineService\(BillingService/); + assert.match(impl, /Code\.Unimplemented/); + }); + + it("with --with-events emits the event-handler proto + vendored option proto", async () => { + await executeGenerateService({ name: "orders", withEvents: true, cwd: dir }); + assert.match(readFileSync(join(dir, "proto/orders/v1/orders.proto"), "utf8"), /service OrdersEventHandlers/); + assert.match(readFileSync(join(dir, "src/services/ordersService.ts"), "utf8"), /EventRoute/); + assert.ok(existsSync(join(dir, "proto/connectum/events/v1/options.proto"))); + }); + + it("rejects a service name with no alphanumeric characters", async () => { + await assert.rejects(() => executeGenerateService({ name: "!!!", cwd: dir }), /no alphanumeric characters/); + }); + + it("refuses to clobber and requires a name", async () => { + await assert.rejects(() => executeGenerateService({ name: " ", cwd: dir }), /service name is required/); + await executeGenerateService({ name: "billing", cwd: dir }); + // Second run without force skips existing files (no throw). + await executeGenerateService({ name: "billing", cwd: dir }); + }); +}); diff --git a/packages/cli/tests/unit/scaffold-events.test.ts b/packages/cli/tests/unit/scaffold-events.test.ts new file mode 100644 index 00000000..bc3c6898 --- /dev/null +++ b/packages/cli/tests/unit/scaffold-events.test.ts @@ -0,0 +1,126 @@ +/** + * Unit tests for the events module fragment (task 2.2): adapter table, proto/option + * generation, EventBus/EventRoute wiring, buf.yaml lint excepts, and composition. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { generateBufYaml } from "../../src/scaffold/bufConfig.ts"; +import { resolveConfig } from "../../src/scaffold/config.ts"; +import { adapterPackage, generateEventBusFile, generateEventRouteFile, generateEventsProto, generateEventsTest } from "../../src/scaffold/eventsFragment.ts"; +import { generateServer } from "../../src/scaffold/serverGen.ts"; +import { transformBase } from "../../src/scaffold/transform.ts"; +import type { ScaffoldConfig } from "../../src/scaffold/types.ts"; + +const natsConfig: ScaffoldConfig = { name: "orders", runtime: "node", packageManager: "pnpm", nodeExec: "raw", sample: true, modules: { events: { adapter: "nats" } } }; + +describe("resolveConfig events flag", () => { + it("parses a valid adapter", () => { + assert.deepEqual(resolveConfig({ name: "x", events: "kafka" }).modules.events, { adapter: "kafka" }); + }); + it("treats an absent flag as disabled", () => { + assert.equal(resolveConfig({ name: "x" }).modules.events, undefined); + }); + it("rejects `--events` passed with no adapter instead of silently disabling it", () => { + assert.throws(() => resolveConfig({ name: "x", events: "" }), /--events requires an adapter/); + assert.throws(() => resolveConfig({ name: "x", events: " " }), /--events requires an adapter/); + }); + it("rejects an unknown adapter", () => { + assert.throws(() => resolveConfig({ name: "x", events: "rabbitmq" }), /invalid --events/); + }); +}); + +describe("events proto + adapter table", () => { + it("proto declares an event-handler service with a topic option", () => { + const p = generateEventsProto(); + assert.match(p, /service GreeterEventHandlers/); + assert.match(p, /rpc OnGreetingSent\(GreetingSent\) returns \(google\.protobuf\.Empty\)/); + assert.match(p, /connectum\.events\.v1\.event\)\.topic/); + assert.match(p, /import "connectum\/events\/v1\/options\.proto"/); + }); + it("maps adapters to their packages", () => { + assert.equal(adapterPackage("nats"), "@connectum/events-nats"); + assert.equal(adapterPackage("kafka"), "@connectum/events-kafka"); + assert.equal(adapterPackage("redpanda"), "@connectum/events-kafka"); + assert.equal(adapterPackage("redis"), "@connectum/events-redis"); + assert.equal(adapterPackage("amqp"), "@connectum/events-amqp"); + }); +}); + +describe("EventBus / EventRoute files", () => { + it("nats EventBus wires createEventBus + NatsAdapter + routes", () => { + const f = generateEventBusFile(natsConfig, "nats"); + assert.match(f, /import \{ NatsAdapter \} from "@connectum\/events-nats"/); + assert.match(f, /createEventBus\(\{/); + assert.match(f, /NatsAdapter\(\{ servers:/); + assert.match(f, /routes: \[greeterEventRoutes\]/); + assert.match(f, /group: "orders"/); + }); + it("kafka EventBus uses KafkaAdapter with brokers + clientId", () => { + const f = generateEventBusFile({ ...natsConfig, modules: { events: { adapter: "kafka" } } }, "kafka"); + assert.match(f, /KafkaAdapter\(\{ brokers:.*clientId: "orders"/s); + }); + it("EventRoute registers the handler with ack (not throw)", () => { + const r = generateEventRouteFile(); + assert.match(r, /events\.service\(GreeterEventHandlers, \{/); + assert.match(r, /async onGreetingSent\(event, ctx\)/); + assert.match(r, /await ctx\.ack\(\)/); + assert.doesNotMatch(r, /throw/); + }); + it("events smoke test uses MemoryAdapter and the runtime-appropriate runner", () => { + assert.match(generateEventsTest("node"), /from "node:test"/); + assert.match(generateEventsTest("bun"), /from "bun:test"/); + assert.match(generateEventsTest("node"), /MemoryAdapter\(\)/); + }); +}); + +describe("buf.yaml lint excepts", () => { + it("adds event-handler lint excepts only when events are enabled", () => { + assert.match(generateBufYaml(natsConfig), /SERVICE_SUFFIX/); + const base: ScaffoldConfig = { ...natsConfig, modules: {} }; + assert.doesNotMatch(generateBufYaml(base), /SERVICE_SUFFIX/); + }); +}); + +describe("generateServer with events", () => { + it("wires eventBus into createServer", () => { + const s = generateServer(natsConfig); + assert.match(s, /import \{ greeterEventBus \} from "#greeterEventBus\.ts"/); + assert.match(s, /eventBus: greeterEventBus,/); + }); +}); + +describe("transformBase with events", () => { + const base = new Map([ + ["package.json", JSON.stringify({ name: "@connectum/example-getting-started", dependencies: { "@connectum/core": "^1.2.0" }, devDependencies: {} })], + ["buf.yaml", "version: v2\n"], + ["src/services/greeterService.ts", "export const greeterService = {};\n"], + ]); + + it("emits option proto, event proto, EventBus, route, and a MemoryAdapter smoke test", () => { + const out = transformBase(base, natsConfig); + assert.ok(out.has("proto/connectum/events/v1/options.proto")); + assert.ok(out.has("proto/greeter/v1/events.proto")); + assert.ok(out.has("src/greeterEventBus.ts")); + assert.ok(out.has("src/services/greeterEvents.ts")); + assert.match(out.get("tests/e2e/events.test.ts") ?? "", /MemoryAdapter/); + }); + + it("adds @connectum/events + the adapter package to dependencies", () => { + const pkg = JSON.parse(transformBase(base, natsConfig).get("package.json") ?? "{}"); + assert.equal(pkg.dependencies["@connectum/events"], "^1.2.0"); + assert.equal(pkg.dependencies["@connectum/events-nats"], "^1.2.0"); + }); + + it("adds module deps even when the base package.json has no dependencies block", () => { + const noDeps = new Map(base); + noDeps.set("package.json", JSON.stringify({ name: "@connectum/example-getting-started", devDependencies: {} })); + const pkg = JSON.parse(transformBase(noDeps, natsConfig).get("package.json") ?? "{}"); + assert.equal(pkg.dependencies["@connectum/events"], "^1.0.0"); + assert.equal(pkg.dependencies["@connectum/events-nats"], "^1.0.0"); + }); + + it("regenerates buf.yaml with the lint excepts", () => { + assert.match(transformBase(base, natsConfig).get("buf.yaml") ?? "", /SERVICE_SUFFIX/); + }); +}); diff --git a/packages/cli/tests/unit/scaffold-prompts.test.ts b/packages/cli/tests/unit/scaffold-prompts.test.ts new file mode 100644 index 00000000..f993732d --- /dev/null +++ b/packages/cli/tests/unit/scaffold-prompts.test.ts @@ -0,0 +1,85 @@ +/** + * Unit tests for the interactive wizard (task 2.1). The prompting logic is tested via + * a stub Prompter (no @clack/prompts loaded), plus the non-interactive short-circuit. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { RawInput } from "../../src/scaffold/config.ts"; +import { collectConfig, isNonInteractive, type Prompter, promptForMissing } from "../../src/scaffold/prompts.ts"; + +function stubPrompter(answers: { text?: string; select?: Record; confirm?: Record } = {}): { prompter: Prompter; calls: string[] } { + const calls: string[] = []; + const prompter: Prompter = { + async text(opts) { + calls.push(`text:${opts.message}`); + return answers.text ?? "stubbed"; + }, + async select(opts) { + calls.push(`select:${opts.message}`); + return (answers.select?.[opts.message] ?? opts.options[0]?.value) as never; + }, + async confirm(opts) { + calls.push(`confirm:${opts.message}`); + return answers.confirm?.[opts.message] ?? false; + }, + }; + return { prompter, calls }; +} + +describe("isNonInteractive", () => { + it("is true with --yes", () => { + assert.equal(isNonInteractive({ yes: true }), true); + }); +}); + +describe("collectConfig", () => { + it("short-circuits in non-interactive mode (returns flags unchanged)", async () => { + const out = await collectConfig({ name: "svc", runtime: "bun", yes: true }); + assert.equal(out.name, "svc"); + assert.equal(out.runtime, "bun"); + }); +}); + +describe("promptForMissing", () => { + it("prompts for every field the flags did not provide", async () => { + const { prompter, calls } = stubPrompter({ text: "myproj" }); + const out = await promptForMissing({}, prompter); + assert.equal(out.name, "myproj"); + assert.equal(out.runtime, "node"); // first select option + assert.equal(out.nodeExec, "raw"); // node -> node-exec asked + assert.equal(out.packageManager, "pnpm"); + assert.ok(calls.includes("text:Project name")); + assert.ok(calls.includes("select:Node execution model")); + assert.ok(calls.includes("confirm:Add an EventBus?")); + }); + + it("does not prompt for fields already provided", async () => { + const flags: RawInput = { name: "svc", runtime: "bun", packageManager: "npm", otel: true, auth: false, events: "kafka", sample: false }; + const { prompter, calls } = stubPrompter(); + const out = await promptForMissing(flags, prompter); + assert.deepEqual(calls, []); + assert.equal(out.events, "kafka"); + assert.equal(out.runtime, "bun"); + }); + + it("skips node-exec for the bun runtime", async () => { + const { prompter, calls } = stubPrompter({ select: { Runtime: "bun" } }); + await promptForMissing({}, prompter); + assert.ok(!calls.includes("select:Node execution model")); + }); + + it("goes straight to the adapter picker when --events was passed with no value", async () => { + const { prompter, calls } = stubPrompter({ select: { "Event adapter": "redis" } }); + const out = await promptForMissing({ events: "" }, prompter); + assert.equal(out.events, "redis"); + assert.ok(!calls.includes("confirm:Add an EventBus?")); + }); + + it("asks for an adapter only when the user opts into events", async () => { + const { prompter, calls } = stubPrompter({ confirm: { "Add an EventBus?": true }, select: { "Event adapter": "amqp" } }); + const out = await promptForMissing({}, prompter); + assert.equal(out.events, "amqp"); + assert.ok(calls.includes("select:Event adapter")); + }); +}); diff --git a/packages/cli/tests/unit/scaffold-serverGen.test.ts b/packages/cli/tests/unit/scaffold-serverGen.test.ts new file mode 100644 index 00000000..0afbd222 --- /dev/null +++ b/packages/cli/tests/unit/scaffold-serverGen.test.ts @@ -0,0 +1,94 @@ +/** + * Unit tests for the composition-root generator (server.ts / index.ts) and the + * otel module fragment — most importantly the interceptor-ordering policy (D-3). + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { generateIndex, generateServer } from "../../src/scaffold/serverGen.ts"; +import { transformPackageJson } from "../../src/scaffold/transform.ts"; +import type { ScaffoldConfig } from "../../src/scaffold/types.ts"; + +const base: ScaffoldConfig = { name: "payments", runtime: "node", packageManager: "pnpm", nodeExec: "raw", sample: true, modules: {} }; +const withOtel: ScaffoldConfig = { ...base, modules: { otel: true } }; + +describe("generateServer", () => { + it("base uses createDefaultInterceptors() and wires health/reflection/greeter", () => { + const s = generateServer(base); + assert.match(s, /interceptors: createDefaultInterceptors\(\)/); + assert.match(s, /Healthcheck\(\{ httpEnabled: true \}\)/); + assert.match(s, /Reflection\(\)/); + assert.match(s, /services: \[greeterService\]/); + assert.doesNotMatch(s, /createOtelInterceptor/); + }); + + it("otel is outermost in the interceptor chain (D-3)", () => { + const s = generateServer(withOtel); + assert.match(s, /import \{ createOtelInterceptor \} from "@connectum\/otel"/); + assert.match(s, /interceptors: \[createOtelInterceptor\(\{ trustRemote: true \}\), \.\.\.createDefaultInterceptors\(\)\]/); + // otel must appear before createDefaultInterceptors in the array (outermost). + const arr = s.slice(s.indexOf("interceptors: [")); + assert.ok(arr.indexOf("createOtelInterceptor") < arr.indexOf("createDefaultInterceptors")); + }); +}); + +describe("resilience + protocol toggles", () => { + it("injects opt-in resilience flags into createDefaultInterceptors", () => { + const s = generateServer({ ...base, modules: { resilience: ["retry", "timeout"] } }); + assert.match(s, /createDefaultInterceptors\(\{ retry: true, timeout: true \}\)/); + }); + + it("merges resilience with the auth errorHandler:false branch", () => { + const s = generateServer({ ...base, modules: { auth: true, resilience: ["retry"] } }); + assert.match(s, /createDefaultInterceptors\(\{ errorHandler: false, retry: true \}\)/); + }); + + it("omits Healthcheck / Reflection when toggled off", () => { + const s = generateServer({ ...base, modules: { healthcheck: false, reflection: false } }); + assert.match(s, /protocols: \[\]/); + assert.doesNotMatch(s, /Healthcheck/); + assert.doesNotMatch(s, /import \{ Reflection \}/); + }); + + it("keeps both protocols by default", () => { + const s = generateServer(base); + assert.match(s, /protocols: \[Healthcheck\(\{ httpEnabled: true \}\), Reflection\(\)\]/); + }); + + it("index.ts drops healthcheckManager when healthcheck is off", () => { + const i = generateIndex({ ...base, modules: { healthcheck: false } }); + assert.doesNotMatch(i, /healthcheckManager/); + }); +}); + +describe("generateIndex", () => { + it("base has no provider lifecycle", () => { + const i = generateIndex(base); + assert.doesNotMatch(i, /initProvider/); + assert.doesNotMatch(i, /shutdownProvider/); + }); + + it("otel initializes and shuts down the provider", () => { + const i = generateIndex(withOtel); + assert.match(i, /import \{ initProvider, shutdownProvider \} from "@connectum\/otel"/); + assert.match(i, /initProvider\(\{ serviceName: "payments" \}\)/); + assert.match(i, /await shutdownProvider\(\)/); + }); +}); + +describe("transformPackageJson (otel module)", () => { + const raw = JSON.stringify({ + name: "@connectum/example-getting-started", + dependencies: { "@connectum/core": "^1.2.0", "@connectrpc/connect": "^2.1.1" }, + }); + + it("adds @connectum/otel on the same slice when otel is enabled", () => { + const pkg = JSON.parse(transformPackageJson(raw, withOtel)); + assert.equal(pkg.dependencies["@connectum/otel"], "^1.2.0"); + }); + + it("does not add @connectum/otel when disabled", () => { + const pkg = JSON.parse(transformPackageJson(raw, base)); + assert.equal(pkg.dependencies["@connectum/otel"], undefined); + }); +}); diff --git a/packages/cli/tests/unit/scaffold-transform.test.ts b/packages/cli/tests/unit/scaffold-transform.test.ts new file mode 100644 index 00000000..0c871cf5 --- /dev/null +++ b/packages/cli/tests/unit/scaffold-transform.test.ts @@ -0,0 +1,198 @@ +/** + * Unit tests for the `connectum init` base transform (pure, no network). + * + * Validates config resolution, script/devDep synthesis (lifecycle-fix, + * package-manager independence, runtime variance), the in-process e2e test + * generation (D-7), and the whole-tree transform. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { resolveConfig } from "../../src/scaffold/config.ts"; +import { + buildDevDeps, + buildScripts, + generateGreeterE2eTest, + transformBase, + transformPackageJson, +} from "../../src/scaffold/transform.ts"; +import type { ScaffoldConfig } from "../../src/scaffold/types.ts"; + +const nodePnpm: ScaffoldConfig = { name: "payments", runtime: "node", packageManager: "pnpm", nodeExec: "raw", sample: true, modules: {} }; + +describe("resolveConfig", () => { + it("applies defaults", () => { + const cfg = resolveConfig({ name: "svc" }); + assert.deepEqual(cfg, { + name: "svc", + runtime: "node", + packageManager: "pnpm", + nodeExec: "raw", + sample: true, + modules: { otel: false, events: undefined, auth: false, resilience: [], healthcheck: true, reflection: true, catalog: false }, + }); + }); + + it("requires a name", () => { + assert.throws(() => resolveConfig({}), /project name is required/); + assert.throws(() => resolveConfig({ name: " " }), /project name is required/); + }); + + it("rejects invalid enum values", () => { + assert.throws(() => resolveConfig({ name: "x", runtime: "deno" }), /invalid --runtime/); + assert.throws(() => resolveConfig({ name: "x", packageManager: "yarn" }), /invalid --package-manager/); + assert.throws(() => resolveConfig({ name: "x", nodeExec: "swc" }), /invalid --node-exec/); + }); +}); + +describe("buildScripts", () => { + it("bakes the lifecycle-fix into typecheck/test/start (not pnpm pre* hooks)", () => { + const s = buildScripts(nodePnpm); + assert.ok(s.typecheck.startsWith("buf generate &&")); + assert.ok(s.test.startsWith("buf generate &&")); + assert.ok(s.start.startsWith("buf generate &&")); + }); + + it("keeps build:proto package-manager-independent (no `pnpm run`)", () => { + const s = buildScripts({ ...nodePnpm, packageManager: "npm" }); + assert.equal(s["build:proto"], "buf generate"); + assert.ok(!s["build:proto"].includes("pnpm")); + }); + + it("targets node with node --test by default", () => { + assert.match(buildScripts(nodePnpm).test, /node --test/); + assert.match(buildScripts(nodePnpm).start, /node src\/index\.ts/); + }); + + it("targets bun with bun test", () => { + const s = buildScripts({ ...nodePnpm, runtime: "bun" }); + assert.match(s.test, /bun test/); + assert.match(s.start, /bun src\/index\.ts/); + }); + + it("uses tsx under the node tsx model", () => { + const s = buildScripts({ ...nodePnpm, nodeExec: "tsx" }); + assert.match(s.start, /tsx src\/index\.ts/); + assert.match(s.test, /--import tsx/); + }); +}); + +describe("buildDevDeps", () => { + const existing = { + "@bufbuild/buf": "^1.65.0", + "@bufbuild/protoc-gen-es": "^2.11.0", + "@connectrpc/connect-node": "^2.1.1", + "@types/node": "^25.2.0", + tsx: "^4.21.0", + typescript: "^5.9.3", + }; + + it("adds @connectum/testing and drops @connectrpc/connect-node", () => { + const d = buildDevDeps(existing, nodePnpm, "^1.0.0"); + assert.equal(d["@connectum/testing"], "^1.0.0"); + assert.equal(d["@connectrpc/connect-node"], undefined); + }); + + it("keeps tsx only under the node tsx model", () => { + assert.equal(buildDevDeps(existing, nodePnpm, "^1.0.0").tsx, undefined); + assert.equal(buildDevDeps(existing, { ...nodePnpm, nodeExec: "tsx" }, "^1.0.0").tsx, "^4.21.0"); + assert.equal(buildDevDeps(existing, { ...nodePnpm, runtime: "bun" }, "^1.0.0").tsx, undefined); + }); +}); + +describe("transformPackageJson", () => { + const raw = JSON.stringify( + { + name: "@connectum/example-getting-started", + private: true, + type: "module", + imports: { "#gen/*": "./gen/*", "#*": "./src/*" }, + scripts: { start: "node src/index.ts" }, + dependencies: { "@connectum/core": "^1.2.0", "@connectrpc/connect": "^2.1.1" }, + devDependencies: { "@bufbuild/buf": "^1.65.0", "@connectrpc/connect-node": "^2.1.1", typescript: "^5.9.3" }, + engines: { node: ">=25.2.0" }, + }, + null, + 2, + ); + + it("sets name, removes private, keeps imports and deps", () => { + const pkg = JSON.parse(transformPackageJson(raw, nodePnpm)); + assert.equal(pkg.name, "payments"); + assert.equal(pkg.private, undefined); + assert.deepEqual(pkg.imports, { "#gen/*": "./gen/*", "#*": "./src/*" }); + assert.equal(pkg.dependencies["@connectum/core"], "^1.2.0"); + }); + + it("pins @connectum/testing to the same slice as @connectum/core", () => { + const pkg = JSON.parse(transformPackageJson(raw, nodePnpm)); + assert.equal(pkg.devDependencies["@connectum/testing"], "^1.2.0"); + }); + + it("sets the node engine floor per exec model", () => { + assert.equal(JSON.parse(transformPackageJson(raw, nodePnpm)).engines.node, ">=25.2.0"); + assert.equal(JSON.parse(transformPackageJson(raw, { ...nodePnpm, nodeExec: "tsx" })).engines.node, ">=22.13.0"); + }); +}); + +describe("generateGreeterE2eTest", () => { + it("uses the in-process createLocalClient and node:test on Node", () => { + const t = generateGreeterE2eTest("node"); + assert.match(t, /from "node:test"/); + assert.match(t, /createLocalClient/); + assert.match(t, /createServer\(\{ services: \[greeterService\] \}\)/); + assert.doesNotMatch(t, /createGrpcTransport/); + }); + + it("uses bun:test on Bun (same body)", () => { + const t = generateGreeterE2eTest("bun"); + assert.match(t, /from "bun:test"/); + assert.match(t, /createLocalClient/); + }); +}); + +describe("transformBase", () => { + const base = new Map([ + [ + "package.json", + JSON.stringify({ name: "@connectum/example-getting-started", private: true, dependencies: { "@connectum/core": "^1.0.0" }, devDependencies: { "@connectrpc/connect-node": "^2.1.1" } }, null, 2), + ], + ["pnpm-workspace.yaml", "packages: []\n"], + ["tests/e2e/e2e.test.ts", "// old test using createGrpcTransport\n"], + ["src/services/greeterService.ts", "export const greeterService = {};\n"], + ["buf.gen.yaml", "version: v2\n"], + ]); + + it("replaces the monorepo pnpm-workspace.yaml and regenerates the e2e test", () => { + const out = transformBase(base, nodePnpm); + // The monorepo workspace file ("packages: []") is replaced by a standalone one. + assert.doesNotMatch(out.get("pnpm-workspace.yaml") ?? "", /packages:/); + assert.match(out.get("tests/e2e/e2e.test.ts") ?? "", /createLocalClient/); + assert.doesNotMatch(out.get("tests/e2e/e2e.test.ts") ?? "", /createGrpcTransport/); + }); + + it("preserves source files and generates a README", () => { + const out = transformBase(base, nodePnpm); + assert.equal(out.get("src/services/greeterService.ts"), "export const greeterService = {};\n"); + // buf.gen.yaml is regenerated (es-only when no catalog). + assert.match(out.get("buf.gen.yaml") ?? "", /protoc-gen-es/); + assert.doesNotMatch(out.get("buf.gen.yaml") ?? "", /catalog/); + assert.match(out.get("README.md") ?? "", /payments/); + }); + + it("transforms package.json (name, no private, testing dep)", () => { + const pkg = JSON.parse(transformBase(base, nodePnpm).get("package.json") ?? "{}"); + assert.equal(pkg.name, "payments"); + assert.equal(pkg.private, undefined); + assert.equal(pkg.devDependencies["@connectum/testing"], "^1.0.0"); + }); + + it("emits a standalone pnpm-workspace.yaml (build-approval) for pnpm, not for npm", () => { + const pnpmOut = transformBase(base, nodePnpm); + // pnpm 11 honours `allowBuilds` (map), NOT `onlyBuiltDependencies` — see transform.ts. + assert.match(pnpmOut.get("pnpm-workspace.yaml") ?? "", /allowBuilds:/); + assert.match(pnpmOut.get("pnpm-workspace.yaml") ?? "", /'@bufbuild\/buf': true/); + const npmOut = transformBase(base, { ...nodePnpm, packageManager: "npm" }); + assert.equal(npmOut.has("pnpm-workspace.yaml"), false); + }); +}); diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index fde75621..13e0ffab 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/index.ts", "src/commands/proto-sync.ts", "src/utils/reflection.ts"], + entry: ["src/index.ts", "src/commands/proto-sync.ts", "src/commands/init.ts", "src/commands/generate-service.ts", "src/utils/reflection.ts", "src/utils/emit.ts"], format: ["esm"], dts: true, sourcemap: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d036a2e7..e469b52c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -214,6 +214,9 @@ importers: '@bufbuild/protobuf': specifier: ^2.12.1 version: 2.12.1 + '@clack/prompts': + specifier: ^1.7.0 + version: 1.7.0 '@connectrpc/connect': specifier: 'catalog:' version: 2.1.2(@bufbuild/protobuf@2.12.1) @@ -226,6 +229,9 @@ importers: citty: specifier: ^0.2.2 version: 0.2.2 + tiged: + specifier: ^2.12.8 + version: 2.12.8 devDependencies: '@biomejs/biome': specifier: 'catalog:' @@ -1010,6 +1016,14 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@clack/core@1.4.3': + resolution: {integrity: sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.7.0': + resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} + engines: {node: '>= 20.12.0'} + '@commitlint/cli@20.5.3': resolution: {integrity: sha512-OJdL0EXWD5y9LPa0nr/geOwzaS8BsdaybKkcloB0JgsguGxNv2R+hC2FTPqrAcprg35zF33KOQerY0x8W1aesA==} engines: {node: '>=v18'} @@ -1851,6 +1865,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -2100,6 +2118,10 @@ packages: chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + chromium-bidi@14.0.0: resolution: {integrity: sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==} peerDependencies: @@ -2131,6 +2153,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + colorette@1.2.1: + resolution: {integrity: sha512-puCDz0CzydiSYOrnXpz/PKd69zRrribezjtE9yd4zvytoRc8+RY/KJPvtPFKZS3E3wP6neGyMe0vOTlHO5L3Pw==} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -2287,6 +2312,10 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enquirer@2.3.6: + resolution: {integrity: sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==} + engines: {node: '>=8.6'} + enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} @@ -2385,9 +2414,18 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + fast-uri@3.1.2: resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -2434,6 +2472,10 @@ packages: fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -2442,6 +2484,10 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -2453,6 +2499,9 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + fuzzysearch@1.0.3: + resolution: {integrity: sha512-s+kNWQuI3mo9OALw0HJ6YGmMbLqEufCh2nX/zzV5CrICQ/y4AwPxM+6TIiF9ItFCHXFCyM/BfCCmN57NTIJuPg==} + generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -2511,10 +2560,16 @@ packages: resolution: {integrity: sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==} engines: {node: '>=20'} + globalyzer@0.1.0: + resolution: {integrity: sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==} + globby@11.1.0: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -2554,6 +2609,10 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} + https-proxy-agent@5.0.0: + resolution: {integrity: sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==} + engines: {node: '>= 6'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -2753,6 +2812,9 @@ packages: jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + kafkajs@2.2.4: resolution: {integrity: sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==} engines: {node: '>=14.0.0'} @@ -2849,16 +2911,33 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + mkdirp@3.0.1: resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} engines: {node: '>=10'} @@ -2867,6 +2946,10 @@ packages: mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + mri@1.1.6: + resolution: {integrity: sha512-oi1b3MfbyGa7FJMP9GmLTttni5JoICpYBRlq+x5V16fZbLsnL9N3wFqqIm/nIG43FjUFkFh9Epzp/kzUGUnJxQ==} + engines: {node: '>=4'} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -3194,6 +3277,11 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + rollup@4.59.0: resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -3262,6 +3350,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -3363,6 +3454,11 @@ packages: tar-stream@3.2.0: resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + teex@1.0.1: resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} @@ -3391,10 +3487,18 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tiged@2.12.8: + resolution: {integrity: sha512-j6h+aYQ6Z2ZXQ0VXxDBdcdbkWeiYn58lZVDKwavYWKOUogAy2Upge/bZSjMAMeSRKwL3gSK3bZ2OK4RgNkCfow==} + engines: {node: '>=8.0.0'} + hasBin: true + timers-browserify@2.0.12: resolution: {integrity: sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==} engines: {node: '>=0.6.0'} + tiny-glob@0.2.8: + resolution: {integrity: sha512-vkQP7qOslq63XRX9kMswlby99kyO5OvKptw7AMwBVMjXEI7Tb61eoI5DydyEMOseyGS5anDN1VPoVxEvH01q8w==} + tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} @@ -3519,6 +3623,10 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -3590,6 +3698,9 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -4054,6 +4165,18 @@ snapshots: human-id: 4.1.3 prettier: 2.8.8 + '@clack/core@1.4.3': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.7.0': + dependencies: + '@clack/core': 1.4.3 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + '@commitlint/cli@20.5.3(@types/node@25.9.3)(conventional-commits-parser@6.4.0)(typescript@5.9.3)': dependencies: '@commitlint/format': 20.5.0 @@ -4888,6 +5011,12 @@ snapshots: acorn@8.16.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + agent-base@7.1.4: optional: true @@ -5154,6 +5283,8 @@ snapshots: chownr@1.1.4: {} + chownr@2.0.0: {} + chromium-bidi@14.0.0(devtools-protocol@0.0.1595872): dependencies: devtools-protocol: 0.0.1595872 @@ -5182,6 +5313,8 @@ snapshots: color-name@1.1.4: {} + colorette@1.2.1: {} + commander@4.1.1: {} compare-func@2.0.0: @@ -5347,6 +5480,10 @@ snapshots: dependencies: once: 1.4.0 + enquirer@2.3.6: + dependencies: + ansi-colors: 4.1.3 + enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 @@ -5471,8 +5608,18 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + fast-uri@3.1.2: {} + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -5525,6 +5672,12 @@ snapshots: fs-constants@1.0.0: {} + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -5537,8 +5690,11 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 - fs.realpath@1.0.0: - optional: true + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + + fs.realpath@1.0.0: {} fsevents@2.3.3: optional: true @@ -5546,6 +5702,8 @@ snapshots: function-bind@1.1.2: optional: true + fuzzysearch@1.0.3: {} + generator-function@2.0.1: optional: true @@ -5624,12 +5782,13 @@ snapshots: minimatch: 10.2.3 once: 1.4.0 path-is-absolute: 1.0.1 - optional: true global-directory@5.0.0: dependencies: ini: 6.0.0 + globalyzer@0.1.0: {} + globby@11.1.0: dependencies: array-union: 2.1.0 @@ -5639,6 +5798,8 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 + globrex@0.1.2: {} + gopd@1.2.0: optional: true @@ -5682,6 +5843,13 @@ snapshots: - supports-color optional: true + https-proxy-agent@5.0.0: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -5713,7 +5881,6 @@ snapshots: dependencies: once: 1.4.0 wrappy: 1.0.2 - optional: true inherits@2.0.4: {} @@ -5898,6 +6065,12 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + kafkajs@2.2.4: {} lazystream@1.0.1: @@ -5983,13 +6156,26 @@ snapshots: minimist@1.2.8: {} + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@5.0.0: {} + minipass@7.1.3: {} + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + mitt@3.0.1: optional: true mkdirp-classic@0.5.3: {} + mkdirp@1.0.4: {} + mkdirp@3.0.1: {} mlly@1.8.2: @@ -5999,6 +6185,8 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.4 + mri@1.1.6: {} + mri@1.2.0: {} ms@2.1.3: {} @@ -6129,8 +6317,7 @@ snapshots: path-exists@4.0.0: {} - path-is-absolute@1.0.1: - optional: true + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -6343,6 +6530,10 @@ snapshots: reusify@1.1.0: {} + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + rollup@4.59.0: dependencies: '@types/estree': 1.0.8 @@ -6451,6 +6642,8 @@ snapshots: signal-exit@4.1.0: {} + sisteransi@1.0.5: {} + slash@3.0.0: {} smart-buffer@4.2.0: @@ -6602,6 +6795,15 @@ snapshots: - bare-buffer - react-native-b4a + tar@6.2.1: + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + teex@1.0.1: dependencies: streamx: 2.28.0 @@ -6661,11 +6863,30 @@ snapshots: dependencies: any-promise: 1.3.0 + tiged@2.12.8: + dependencies: + colorette: 1.2.1 + enquirer: 2.3.6 + fs-extra: 10.1.0 + fuzzysearch: 1.0.3 + https-proxy-agent: 5.0.0 + mri: 1.1.6 + rimraf: 3.0.2 + tar: 6.2.1 + tiny-glob: 0.2.8 + transitivePeerDependencies: + - supports-color + timers-browserify@2.0.12: dependencies: setimmediate: 1.0.5 optional: true + tiny-glob@0.2.8: + dependencies: + globalyzer: 0.1.0 + globrex: 0.1.2 + tinyexec@0.3.2: {} tinyexec@1.1.2: {} @@ -6778,6 +6999,8 @@ snapshots: universalify@0.1.2: {} + universalify@2.0.1: {} + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 @@ -6858,6 +7081,8 @@ snapshots: yallist@3.1.1: optional: true + yallist@4.0.0: {} + yaml@2.9.0: {} yargs-parser@21.1.1: {}