Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/swift-pandas-scaffold.md
Original file line number Diff line number Diff line change
@@ -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 <name>`** 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.
90 changes: 90 additions & 0 deletions .github/workflows/cli-scaffold-matrix.yml
Original file line number Diff line number Diff line change
@@ -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:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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:
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
61 changes: 60 additions & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <name> [--with-events] [--force]
```

Scaffolds `proto/<name>/v1/<name>.proto` and `src/services/<name>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.
Expand Down
16 changes: 15 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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:",
Expand Down
95 changes: 95 additions & 0 deletions packages/cli/src/commands/generate-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* `connectum generate service <name>` — 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<void> {
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,
});
},
});
Loading
Loading