- name
- launch-light
- description
- Lightweight productivity bootstrap for greenfield TypeScript projects — pnpm workspace with apps/api/ (NestJS) by default, BFF-ready so adding apps/web/ later requires zero restructuring. OpenRouter + LangSmith env slots, a colored start-services.sh launcher that hard-fails when a service doesn't come up, README, and .env.example. NO Docker, NO Postgres, NO infra. Generates a runnable scaffold in seconds without asking domain questions. Use when the user says "/launch-light", "init", "init setup", "bootstrap minimal", "spin up Nest", "give me a scratch Nest app". Hands off to /launch-scratch-project the moment Docker/Postgres/Redis/queues/multi-service enter the picture.
- author
- DevOtts
- author_url
- https://github.com/DevOtts
# launch-light
Fast, opinionated bootstrap for a greenfield TypeScript service. **Always BFF-ready**: scaffolds a pnpm workspace with `apps/api/` (NestJS) on day 1, so when someone later asks for a frontend it's just `apps/web/` dropped in next to it — no restructuring, no shared-tsconfig surprises, no "the API tsconfig swept the React code" failure modes.
**No Docker. No databases. No queues.** The moment the user needs any of those, hand off to `/launch-scratch-project`.
## Soft reminder (not a gate)
If this scaffold is for a graded exercise (interview, take-home, coaching session), spend a minute framing the problem out loud first — the scaffold is intentionally trivial so the graded thinking happens after, not during. But this skill will *not* refuse to run if you skip the framing — that's the user's call.
## When to use this skill vs `/launch-scratch-project`
| Use `launch-light` | Use `/launch-scratch-project` |
|---|---|
| NestJS API (and optionally a Next.js/Vite app later) | Need Postgres/Redis/MongoDB/RabbitMQ/MinIO/etc. |
| In-memory state or JSON-file DB | Need a docker-compose stack |
| Single-shot scaffold, no questionnaire | Want the multi-service questionnaire + plan-approval flow |
| `start-services.sh` for `pnpm dev` | `init.sh` for `docker compose up` + per-service docs |
| < 5 minutes of bootstrap | Multi-service spike or interview build |
If the user mentions any of: **Postgres, Redis, MongoDB, RabbitMQ, Kafka, MinIO, MailHog, ClickHouse, docker, queue, broker, persistence, multi-service** → STOP and say:
> "That needs `/launch-scratch-project` — it has the questionnaire for picking services and generates the docker-compose stack. `/launch-light` is no-infra by design."
Don't extend `launch-light` with Docker — that's the explicit boundary.
## Decoding the user's prompt
There's only one mode: pnpm workspace with `apps/api/`. Parse for these optional flags:
| User says | Effect |
|---|---|
| `/launch-light`, `init`, `init setup`, `bootstrap`, just NestJS | default workspace + apps/api |
| `with frontend`, `with web`, `BFF`, `+ web`, `+ Next` | ALSO scaffold `apps/web/` (Next.js 15) on the same pass |
| `--skip-llm` or `no openrouter` | drop OpenRouter env + helper |
| `--skip-langsmith` or `no tracing` | drop LangSmith env + package |
Don't ask a questionnaire. Generate, hand back. If the user later wants a frontend, `/iteration-impl` will drop `apps/web/` in alongside `apps/api/` — the workspace shape never has to change.
## What it generates
```
.
├── apps/
│ └── api/ # NestJS — :3001 — the single-app default
│ ├── src/
│ │ ├── main.ts # bootstrap, listens on :3001
│ │ ├── app.module.ts
│ │ ├── app.controller.ts # GET /health → { ok: true }
│ │ ├── app.controller.spec.ts # seed failing test (intentional RED)
│ │ └── llm/
│ │ └── openrouter.ts # configured OpenAI-SDK client → OpenRouter
│ ├── package.json # "name": "api", nest start, vitest
│ ├── tsconfig.json # scoped: include src/**/*.ts, exclude dist/node_modules
│ ├── vitest.config.ts
│ └── nest-cli.json
├── .env # symlinked into each app by start-services.sh
├── .env.example # OPENROUTER_*, LANGSMITH_*, PORT
├── .gitignore
├── package.json # workspace root (pnpm -r dev, no app deps here)
├── pnpm-workspace.yaml # packages: ["apps/*"]
├── start-services.sh # chmod +x — runs whatever apps it finds, hard-fails on unhealthy
└── README.md
```
If the user invoked with `+ web` / BFF, ALSO scaffold:
```
└── apps/
└── web/ # Next.js 15 App Router — :3000
├── app/page.tsx # calls API via /api proxy
├── app/api/health/route.ts
├── next.config.ts # rewrites /api/* → http://localhost:3001/*
├── package.json
└── tsconfig.json # scoped to its own dir, "jsx": "preserve"
```
The workspace shape is the load-bearing decision. Adding `apps/web/` later (manually or via `/iteration-impl`) requires no restructuring because each app already has its own `package.json` and `tsconfig.json` — they were never coupled.
In all cases:
- **Vitest** is the test runner (not Jest — faster, consistent across web + api)
- **One seed failing test** so TDD has a RED to start from
- **`start-services.sh`** is the canonical launcher; `pnpm -r --parallel dev` works too
- **`.runtime/`** holds process logs (gitignored)
## Phase 1 — Confirm and dispatch
Print a one-screen confirmation (no questionnaire):
```
## launch-light bootstrap
Layout: pnpm workspace — apps/api/ [+ apps/web/ if requested]
LLM: OpenRouter (env slot + apps/api/src/llm/openrouter.ts helper) [or "skipped"]
Tracing: LangSmith (env slot + langsmith package) [or "skipped"]
Runner: start-services.sh (colored, port preflight, watchdog, hard-fails on API/Web not ready)
Tests: Vitest with one seed failing test
Files I'll write:
- package.json (workspace root), pnpm-workspace.yaml
- apps/api/{package.json, tsconfig.json, vitest.config.ts, nest-cli.json}
- apps/api/src/{main.ts, app.module.ts, app.controller.ts (+ spec)}
- apps/api/src/llm/openrouter.ts (if LLM enabled)
- apps/web/* (only if + web was requested)
- .env.example, .gitignore
- start-services.sh (chmod +x)
- README.md
Proceed? (y/n)
```
If `y` → write everything in one pass. If `n` → ask what to change.
## Phase 2 — Generate
### 2.1 Workspace root
`pnpm-workspace.yaml`:
```yaml
packages:
- "apps/*"
```
`package.json` (workspace root):
```json
{
"name": "<project>",
"private": true,
"scripts": {
"dev": "pnpm -r --parallel dev",
"build": "pnpm -r build",
"test": "pnpm -r test"
},
"devDependencies": {
"typescript": "^5.6.0"
}
}
```
### 2.2 `apps/api/package.json`
```json
{
"name": "api",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "nest start --watch",
"build": "nest build",
"start": "node dist/main.js",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@nestjs/common": "^10.4.0",
"@nestjs/core": "^10.4.0",
"@nestjs/platform-express": "^10.4.0",
"dotenv": "^16.4.0",
"openai": "^4.70.0",
"langsmith": "^0.2.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.0",
"@nestjs/testing": "^10.4.0",
"@types/node": "^22.0.0",
"typescript": "^5.6.0",
"vitest": "^2.1.0",
"vite-tsconfig-paths": "^5.0.0"
}
}
```
Drop `langsmith` if `--skip-langsmith`. Drop `openai` if `--skip-llm`.
### 2.3 `apps/api/tsconfig.json` — scoped from day 1
```json
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": ["src/**/*.ts", "scripts/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
```
**Why explicit `include`/`exclude`:** without them, `tsc --watch` (spawned by `nest start --watch`) sweeps the whole project tree. If a sibling folder ever appears (e.g. `apps/web/`, `evals/`, a scaffolded mockup) and the tsconfig is unscoped, tsc tries to compile its `.ts`/`.tsx` files, fails on JSX or React types, and **Nest never boots cleanly — the API silently dies and the only symptom is `Internal Server Error` from a frontend proxy.** Ship the tsconfig scoped from day 1; it costs nothing.
### 2.4 NestJS source files (`apps/api/src/`)
**`main.ts`**
```typescript
import 'dotenv/config'; // MUST be first — populates process.env before any module that reads env at load time (e.g. an OpenAI/LangSmith client constructed at module scope). nest start does NOT auto-load .env.
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const port = Number(process.env.PORT ?? 3001);
await app.listen(port);
console.log(`API listening on http://localhost:${port}`);
}
bootstrap();
```
**`app.module.ts`**
```typescript
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
@Module({
controllers: [AppController],
})
export class AppModule {}
```
**`app.controller.ts`**
```typescript
import { Controller, Get } from '@nestjs/common';
@Controller()
export class AppController {
@Get('health')
health() {
return { ok: true };
}
}
```
**`app.controller.spec.ts`** (seed failing test — intentional RED)
```typescript
import { describe, it, expect } from 'vitest';
import { AppController } from './app.controller';
describe('AppController', () => {
it('health returns ok=true', () => {
const c = new AppController();
expect(c.health()).toEqual({ ok: true });
});
it('TODO: replace this with the real first feature test', () => {
expect.fail('Seed RED — delete or rewrite this test as your first real failing test.');
});
});
```
The first test passes; the second is the deliberate RED that the TDD loop starts from.
### 2.5 OpenRouter helper (if LLM enabled)
**`apps/api/src/llm/openrouter.ts`**
```typescript
import OpenAI from 'openai';
import { wrapOpenAI } from 'langsmith/wrappers';
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
throw new Error('OPENROUTER_API_KEY is missing — see .env.example');
}
// `wrapOpenAI` emits LangSmith runs with token counts + cost estimates for every
// `chat.completions.create` call.
//
// Two enhancements you almost always want on top of the bare wrapper:
//
// 1. NAME EVERY CALL. By default each run shows up in LangSmith as "ChatOpenAI",
// which is useless when one request fires 3+ LLM calls. Pass a descriptive
// `name` via `langsmithExtra` on each call — see `lsExtra` helper below.
//
// 2. WRAP THE CALLER WITH `traceable` TO GET A WATERFALL. Without a parent span
// every `chat.completions.create` is a top-level run — flat list, no chain
// of thought. Wrapping the function that makes the calls creates a parent
// span; nested wrapOpenAI calls auto-attach as children via AsyncLocalStorage.
// See "LangSmith tracing waterfall" below for the pattern.
//
// COST CAVEAT: LangSmith computes cost from its built-in price table for the
// un-prefixed model id (e.g. `claude-sonnet-4-5`, not the OpenRouter slug
// `anthropic/claude-sonnet-4.5`). OpenRouter adds margin on top, so the "cost"
// number is directional, not your actual OpenRouter bill. Token counts are
// correct.
export const openrouter = wrapOpenAI(
new OpenAI({
apiKey,
baseURL: 'https://openrouter.ai/api/v1',
defaultHeaders: {
'HTTP-Referer': process.env.OPENROUTER_SITE_URL ?? 'http://localhost:3001',
'X-Title': process.env.OPENROUTER_APP_NAME ?? '<project>',
},
}),
);
export const DEFAULT_MODEL = process.env.OPENROUTER_MODEL ?? 'anthropic/claude-sonnet-4.5';
// LangSmith's pricing table keys on un-prefixed canonical model ids
// (e.g. `claude-sonnet-4-5`, not the OpenRouter slug `anthropic/claude-sonnet-4.5`).
export function toLangSmithModelName(openrouterModelId: string): string {
View on GitHub