Table of Contents

1. Building CLIs 2. TUIs with Ink (React for the Terminal) 3. HTTP Frameworks: Express, Fastify, Hono 4. Project: AI Git Commit Message Tool 5. Building Platform SDKs 6. Publishing to npm 7. Docker for TypeScript 8. TS Project Structure Patterns

1. Building CLIs

Project Setup

Bash
mkdir my-cli && cd my-cli
pnpm init
pnpm add -D typescript @types/node tsup
pnpm add commander chalk     # CLI framework + colors
JSON
// package.json
{
  "name": "my-cli",
  "version": "1.0.0",
  "type": "module",
  "bin": {
    "my-cli": "./dist/index.js"
  },
  "scripts": {
    "build": "tsup src/index.ts --format esm --target node20",
    "dev": "tsup src/index.ts --format esm --watch"
  }
}

CLI with Commander

TypeScript
// src/index.ts
#!/usr/bin/env node
import { Command } from "commander";
import chalk from "chalk";

const program = new Command();

program
  .name("my-cli")
  .description("A tool that does cool stuff")
  .version("1.0.0");

program
  .command("greet")
  .description("Greet someone")
  .argument("<name>", "person to greet")
  .option("-l, --loud", "shout the greeting")
  .action((name, options) => {
    let msg = `Hello, ${name}!`;
    if (options.loud) msg = msg.toUpperCase();
    console.log(chalk.green(msg));
  });

program
  .command("init")
  .description("Initialize a new project")
  .option("--template <name>", "template to use", "default")
  .action(async (options) => {
    console.log(chalk.blue(`Creating project with ${options.template} template...`));
    // scaffold files here
  });

program.parse();

Interactive Prompts

TypeScript
import { input, select, confirm } from "@inquirer/prompts";

const name = await input({ message: "Project name?" });
const template = await select({
  message: "Pick a template",
  choices: [
    { name: "Minimal", value: "minimal" },
    { name: "Full", value: "full" },
  ],
});
const ok = await confirm({ message: "Proceed?" });
The shebang line #!/usr/bin/env node at the top of your entry file tells the OS to run it with Node. Without it, my-cli won't work when installed globally.

2. TUIs with Ink (React for the Terminal)

Ink lets you build terminal UIs using React components. State management, hooks, and JSX -- but rendered to the terminal instead of a browser.

Bash
pnpm add ink react
pnpm add -D @types/react
TypeScript
// src/ui.tsx
import React, { useState, useEffect } from "react";
import { render, Text, Box, useInput } from "ink";

function App() {
  const [count, setCount] = useState(0);
  const [items] = useState(["Build CLI", "Write tests", "Deploy"]);
  const [selected, setSelected] = useState(0);

  useInput((input, key) => {
    if (key.upArrow) setSelected(s => Math.max(0, s - 1));
    if (key.downArrow) setSelected(s => Math.min(items.length - 1, s + 1));
    if (input === "q") process.exit();
  });

  return (
    <Box flexDirection="column" padding={1}>
      <Text bold color="green">My TUI App</Text>
      {items.map((item, i) => (
        <Text key={i} color={i === selected ? "cyan" : "white"}>
          {i === selected ? "❯ " : "  "}{item}
        </Text>
      ))}
      <Text dimColor>↑↓ navigate, q quit</Text>
    </Box>
  );
}

render(<App />);
Ink components: <Text> for text, <Box> for flexbox layout, <Spinner> for loading indicators, <TextInput> for user input. Ink also supports useStdout, useStdin, and custom hooks.

3. HTTP Frameworks: Express, Fastify, Hono

Express (Most Popular)

TypeScript
import express from "express";

const app = express();
app.use(express.json());

app.get("/api/users", (req, res) => {
  res.json([{ id: 1, name: "Sean" }]);
});

app.post("/api/users", (req, res) => {
  const { name, email } = req.body;
  res.status(201).json({ id: "new-id", name, email });
});

app.listen(3000);

Fastify (Fastest)

TypeScript
import Fastify from "fastify";

const app = Fastify({ logger: true });

app.get("/api/users", async (req, reply) => {
  return [{ id: 1, name: "Sean" }];
});

// Schema validation built in
app.post("/api/users", {
  schema: {
    body: {
      type: "object",
      required: ["name", "email"],
      properties: {
        name: { type: "string" },
        email: { type: "string", format: "email" },
      },
    },
  },
}, async (req, reply) => {
  reply.code(201);
  return { id: "new-id", ...req.body };
});

app.listen({ port: 3000 });

Hono (Edge + Node)

TypeScript
import { Hono } from "hono";
import { serve } from "@hono/node-server";

const app = new Hono();

app.get("/api/users", (c) => {
  return c.json([{ id: 1, name: "Sean" }]);
});

app.post("/api/users", async (c) => {
  const body = await c.req.json();
  return c.json({ id: "new-id", ...body }, 201);
});

serve(app, { port: 3000 });
Text
Framework   Speed      Ecosystem      Best For
──────────────────────────────────────────────────────
Express     Slow       Massive        Legacy, middleware-heavy apps
Fastify     Fast       Growing        Performance-critical APIs
Hono        Fastest    Small          Edge computing, lightweight APIs

4. Project: AI Git Commit Message Tool

A real CLI tool that reads your git diff, sends it to an AI, and generates a commit message. Here's the architecture.

Text
Flow:
1. Run `git diff --staged` to get changes
2. Send diff to AI API (Anthropic/OpenAI)
3. AI generates a commit message
4. Show to user, let them accept/edit/reject
5. Run `git commit -m "..."`
TypeScript
// src/index.ts
#!/usr/bin/env node
import { execSync } from "node:child_process";
import Anthropic from "@anthropic-ai/sdk";
import { confirm, editor } from "@inquirer/prompts";

const client = new Anthropic();

async function main() {
  // 1. Get the staged diff
  const diff = execSync("git diff --staged", { encoding: "utf8" });
  if (!diff.trim()) {
    console.log("No staged changes. Stage files with `git add` first.");
    process.exit(1);
  }

  // 2. Send to AI
  const response = await client.messages.create({
    model: "claude-sonnet-4-6",
    max_tokens: 300,
    messages: [{
      role: "user",
      content: `Generate a concise git commit message for this diff.
Use conventional commit format (feat:, fix:, refactor:, etc.).
One line summary, then optional body.\n\n${diff}`
    }],
  });

  const message = response.content[0].type === "text"
    ? response.content[0].text : "";

  console.log(`\nSuggested commit message:\n${message}\n`);

  // 3. Let user accept, edit, or reject
  const accept = await confirm({ message: "Use this message?" });

  if (accept) {
    execSync(`git commit -m "${message.replace(/"/g, '\\"')}"`);
    console.log("Committed!");
  } else {
    const edited = await editor({ message: "Edit the message:", default: message });
    execSync(`git commit -m "${edited.replace(/"/g, '\\"')}"`);
    console.log("Committed with edited message!");
  }
}

main().catch(console.error);
JSON
// package.json
{
  "name": "ai-commit",
  "bin": { "ai-commit": "./dist/index.js" },
  "dependencies": {
    "@anthropic-ai/sdk": "^0.30.0",
    "@inquirer/prompts": "^5.0.0"
  }
}
To use: Set ANTHROPIC_API_KEY env var, then git add . and run ai-commit. It reads your diff, generates a message, and commits.

5. Building Platform SDKs

An SDK wraps an API so users don't deal with raw HTTP. Here's the pattern.

TypeScript
// src/client.ts
interface ClientConfig {
  apiKey: string;
  baseUrl?: string;
}

export class MyPlatformClient {
  private apiKey: string;
  private baseUrl: string;

  constructor(config: ClientConfig) {
    this.apiKey = config.apiKey;
    this.baseUrl = config.baseUrl ?? "https://api.myplatform.com";
  }

  private async request<T>(path: string, options?: RequestInit): Promise<T> {
    const res = await fetch(`${this.baseUrl}${path}`, {
      ...options,
      headers: {
        "Authorization": `Bearer ${this.apiKey}`,
        "Content-Type": "application/json",
        ...options?.headers,
      },
    });

    if (!res.ok) {
      const body = await res.text();
      throw new ApiError(res.status, body);
    }

    return res.json() as Promise<T>;
  }

  // Resource-specific methods
  readonly users = {
    list: () => this.request<User[]>("/users"),
    get: (id: string) => this.request<User>(`/users/${id}`),
    create: (data: CreateUserInput) =>
      this.request<User>("/users", {
        method: "POST",
        body: JSON.stringify(data),
      }),
  };
}

// Usage:
const client = new MyPlatformClient({ apiKey: "sk-..." });
const users = await client.users.list();
const user = await client.users.create({ name: "Sean" });

6. Publishing to npm

Package Setup

JSON
{
  "name": "@yourname/my-package",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    }
  },
  "files": ["dist"],         // only publish dist/
  "scripts": {
    "build": "tsup src/index.ts --format cjs,esm --dts",
    "prepublishOnly": "npm run build"
  }
}

Publish Steps

Bash
# Login to npm
npm login

# Check what will be published
npm pack --dry-run

# Publish (public scoped package)
npm publish --access public

# Bump version and publish
npm version patch   # 1.0.0 → 1.0.1
npm version minor   # 1.0.1 → 1.1.0
npm version major   # 1.1.0 → 2.0.0
npm publish
The files field is critical. Without it, npm publishes your entire project (including src, tests, configs). Set "files": ["dist"] to only publish the built output. Also add a .npmignore or rely on files as an allowlist.

7. Docker for TypeScript

Multi-Stage Build (The Right Way)

Dockerfile
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY tsconfig.json ./
COPY src/ ./src/
RUN pnpm build

# Stage 2: Production (no dev deps, no TS source)
FROM node:20-alpine
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile --prod
COPY --from=builder /app/dist ./dist
USER node
CMD ["node", "dist/index.js"]

Why Multi-Stage?

Text
Single stage:  ~800MB (includes TS, dev deps, source)
Multi-stage:   ~150MB (only JS output + prod deps)

docker-compose.yml for Dev

YAML
services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgres://user:pass@db:5432/mydb
    depends_on:
      - db

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: mydb
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:
Dev workflow: Use docker compose up for databases and external services, but run your TS app locally with tsx watch src/index.ts for fast iteration. Only Dockerize the TS app for production builds and CI.

8. TS Project Structure Patterns

Small CLI / Library

Text
my-lib/
├── src/
│   ├── index.ts          ← entry point, re-exports
│   ├── client.ts
│   └── types.ts
├── tests/
│   └── client.test.ts
├── package.json
├── tsconfig.json
└── tsup.config.ts

API Server

Text
my-api/
├── src/
│   ├── index.ts          ← server entry
│   ├── routes/
│   │   ├── users.ts
│   │   └── posts.ts
│   ├── middleware/
│   │   ├── auth.ts
│   │   └── validate.ts
│   ├── services/          ← business logic
│   │   └── user-service.ts
│   ├── db/
│   │   ├── client.ts
│   │   └── schema.ts
│   └── types/
│       └── index.ts
├── tests/
├── Dockerfile
├── docker-compose.yml
├── package.json
└── tsconfig.json

Full-Stack Monorepo

Text
my-app/
├── packages/
│   ├── shared/            ← types, utils, validation
│   │   ├── src/
│   │   └── package.json
│   ├── api/               ← backend
│   │   ├── src/
│   │   ├── Dockerfile
│   │   └── package.json
│   └── web/               ← frontend
│       ├── src/
│       └── package.json
├── package.json           ← workspaces root
├── pnpm-workspace.yaml
├── turbo.json
└── tsconfig.base.json     ← shared TS config
Key principle: Separate what your code does (services, business logic) from how it's delivered (routes, CLI commands, UI components). This makes it testable and reusable.