Skip to content
Merged
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
46 changes: 43 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ Open <http://localhost:3000>, put in your email address, and click the link.
| **Feeds** | RSS for the board and for every forum, permission-checked exactly as the page is. |
| **Ads** | The [CrawlProof](https://crawlproof.com/ads) ad network, on by default, in one CSP-safe iframe. |
| **A terminal client** | `tsbb-tui` — read and post against any board over SSH. |
| **An API** | A permission-checked REST API with an OpenAPI description at `/api/v1/openapi.json`. |
| **A CLI** | `tsbb` reads and posts against any board from a shell, with `--json` on every command. |
| **An MCP server** | Served at `/api/mcp`, and as `tsbb-mcp` over stdio, so an assistant can use the board as a member. |

## Design decisions worth knowing before you read the code

Expand Down Expand Up @@ -158,16 +161,47 @@ what the API would show a browser.
2 posts r reply · j/k move · backspace back · ? help · q quit
```

## Four ways in

The board is one thing with four front doors, and they are the same board: every
one of them resolves the same permissions, so a token can never read what a
browser would hide, and there is no second data path to drift out of step.

| | |
|---|---|
| **The pages** | Server-rendered HTML, no client-side JavaScript. |
| **[The API](docs/API.md)** | `GET /api/v1` describes itself; `/api/v1/openapi.json` describes the rest. Reading is open to whatever the board shows a guest; posting needs a token. |
| **[The CLI](docs/CLI.md)** | `tsbb` runs a board *and* uses one. `tsbb read 42`, `tsbb post general "Title" < body.md`, `--json` on everything. |
| **[MCP](docs/MCP.md)** | The board serves MCP at `/api/mcp`; `tsbb-mcp` serves the same tools over stdio for assistants that launch a subprocess. |

Clients get a token through the device flow — the board shows a short code, a
human approves it in a browser, and the client is handed a token once. A token
is never an administrator, however it was minted.

Every board serves these four documents at **`/docs`** — they are the files in
`docs/`, rendered by the board's own markdown renderer, so the site cannot
quietly disagree with the repository. On tsbb.dev that is
**[tsbb.dev/docs](https://tsbb.dev/docs)**.

## Commands

```
Running a board:
tsbb init Create .env, migrate and seed a new board
tsbb serve [--port N] Run the board (it migrates at boot)
tsbb worker Run the mail worker separately
tsbb status What this board is and how big it is
tsbb admin <email> Make somebody an administrator
tsbb invite <email> Email somebody a sign-in link
tsbb plugin ls|enable|disable

Using a board — yours or anybody's:
tsbb login [server] Approve a code in a browser; the token is stored
tsbb boards | use | whoami Several boards at once, one of them current
tsbb forums | latest | topics <forum> | read <id> | search <words…> | inbox
tsbb post <forum> "<title>" [body]
tsbb reply <topic-id> [body]
tsbb mcp [--read-only] Serve the current board to an assistant over MCP
```

## Configuration
Expand Down Expand Up @@ -201,11 +235,14 @@ packages/
design-tokens shadcn tokens in oklch
ui server-rendered components
mail transports and templates
client one REST client, shared by the TUI, the CLI and MCP
mcp the MCP tools and protocol, transport-agnostic
apps/
server the board
server the board (and /api/mcp)
worker notification email and housekeeping
cli tsbb
tui tsbb-tui
mcp tsbb-mcp
plugins/
crawlproof-ads
hello-world a worked example — copy it
Expand All @@ -214,14 +251,17 @@ plugins/
## Development

```
pnpm test # 78 tests, no network, no fixtures
pnpm test # 145 tests, no fixtures
pnpm typecheck
pnpm dev
```

The tests boot the real app and drive it through `app.fetch`, and the TUI tests
render the real views through hqtui's headless renderer — so what is asserted is
the bytes that would reach a browser or a terminal.
the bytes that would reach a browser or a terminal. The CLI and `tsbb-mcp` tests
go further and bind a real port, because the failures worth catching there — a
token not found where it was saved, a stray log line on stdout breaking the MCP
framing — only happen when it is done for real.

## Deployment

Expand Down
146 changes: 145 additions & 1 deletion apps/cli/bin/tsbb.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const REPO_ROOT = resolve(HERE, '../../..');

const USAGE = `tsbb — a TypeScript bulletin board

Usage:
Running a board (these read the database beside you):
tsbb init Create .env, migrate and seed a new board
tsbb serve [--port N] Run the board (migrates at boot)
tsbb worker Run the mail worker on its own
Expand All @@ -22,6 +22,30 @@ Usage:
tsbb plugin enable <slug> Turn one on
tsbb plugin disable <slug> Turn one off

Using a board (these talk to one over its API, yours or anybody's):
tsbb login [server] Sign in by approving a code in a browser
tsbb logout [server] Forget the token for a board
tsbb boards Every board you are signed in to
tsbb use <server> Make one of them the default
tsbb whoami Who you are on the current board

tsbb forums The forums you can read
tsbb latest Recently active topics
tsbb topics <forum> Topics in one forum
tsbb read <topic-id> A topic, as text
tsbb search <words…> Full-text search
tsbb inbox Your notifications

tsbb post <forum> "<title>" [body] Start a topic (body may be piped in)
tsbb reply <topic-id> [body] Reply to one (body may be piped in)

tsbb mcp [--read-only] Serve this board to an AI assistant over MCP

Flags for the commands above:
--server <url> Talk to that board instead of the current one
--json Print the API's JSON, for piping into something else
--limit <n> How many rows to fetch

Configuration comes from the environment, or a .env beside you:
TSBB_DATABASE_URL file:./data/tsbb.db (or a libsql:// URL for Turso)
TSBB_BASE_URL http://localhost:3000
Expand Down Expand Up @@ -60,6 +84,29 @@ if (existsSync(envPath)) {

const baseUrl = process.env.TSBB_BASE_URL ?? 'http://localhost:3000';

/**
* Split the flags out of the arguments.
*
* The remote commands take their flags anywhere, because `tsbb read 12 --json`
* and `tsbb --json read 12` are both what people type, and a CLI that accepts
* only one of them is a CLI you have to remember the shape of.
*/
function parseFlags(args) {
const flags = {};
const rest = [];
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === '--json') flags.json = true;
else if (arg === '--read-only') flags.readOnly = true;
else if (arg === '--server' || arg === '-s') flags.server = args[(i += 1)];
else if (arg === '--limit' || arg === '-n') flags.limit = Number(args[(i += 1)]);
else if (arg?.startsWith('--server=')) flags.server = arg.slice(9);
else if (arg?.startsWith('--limit=')) flags.limit = Number(arg.slice(8));
else rest.push(arg);
}
return { flags, rest };
}

try {
switch (command) {
case 'init': {
Expand Down Expand Up @@ -122,6 +169,103 @@ try {
} else throw new Error(`Unknown: tsbb plugin ${sub}`);
process.exit(process.exitCode ?? 0);
}
/*
* Everything below talks to a board over HTTP and never opens the database,
* so these work from any directory, with no .env and no checkout — which is
* the point of them. Failures are reported by reportRemoteError rather than
* the catch below, because "not signed in" deserves better than a stack.
*/
case 'login':
case 'logout':
case 'boards':
case 'use':
case 'whoami':
case 'forums':
case 'latest':
case 'topics':
case 'read':
case 'search':
case 'inbox':
case 'post':
case 'reply': {
const remote = await import('../src/remote.ts');
const { flags, rest } = parseFlags(argv.slice(1));
try {
switch (command) {
case 'login':
await remote.loginCommand(rest[0], flags);
break;
case 'logout':
remote.logoutCommand(rest[0], flags);
break;
case 'boards':
remote.boardsCommand(flags);
break;
case 'use':
if (!rest[0]) throw new Error('Which board? Run: tsbb use <server>');
remote.useCommand(rest[0], flags);
break;
case 'whoami':
await remote.whoamiCommand(flags);
break;
case 'forums':
await remote.forumsCommand(flags);
break;
case 'latest':
await remote.latestCommand(flags);
break;
case 'topics':
await remote.topicsCommand(rest[0], flags);
break;
case 'read':
await remote.readCommand(rest[0], flags);
break;
case 'search':
await remote.searchCommand(rest, flags);
break;
case 'inbox':
await remote.inboxCommand(flags);
break;
case 'post':
await remote.postCommand(rest, flags);
break;
case 'reply':
await remote.replyCommand(rest, flags);
break;
}
} catch (error) {
remote.reportRemoteError(error);
}
process.exit(process.exitCode ?? 0);
}

/*
* The MCP server. It holds the current board's token and speaks over stdio,
* which is what an assistant's config launches. Nothing may reach stdout
* except protocol messages, so there is no banner here — the startup line
* goes to stderr, where clients show it as a log.
*/
case 'mcp': {
const remote = await import('../src/remote.ts');
const { flags } = parseFlags(argv.slice(1));
try {
const { serveStdio } = await import('@tsbb/mcp');
const { readFileSync } = await import('node:fs');
const client = remote.clientFor(flags);
const me = await client.me().catch(() => ({ authenticated: false }));
const allowWrites = !flags.readOnly && me.authenticated;
const pkg = JSON.parse(readFileSync(join(HERE, '../package.json'), 'utf8'));

process.stderr.write(
`[tsbb-mcp] ${client.server} — ${me.authenticated ? `signed in as ${me.user?.username}` : 'not signed in'}, writes ${allowWrites ? 'on' : 'off'}\n`,
);
await serveStdio({ client, allowWrites, version: pkg.version ?? '0.0.0' });
} catch (error) {
remote.reportRemoteError(error);
}
process.exit(process.exitCode ?? 0);
}

default:
console.error(`Unknown command: ${command}\n`);
console.log(USAGE);
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
"bin": { "tsbb": "./bin/tsbb.mjs" },
"engines": { "node": ">=24" },
"dependencies": {
"@tsbb/client": "workspace:*",
"@tsbb/core": "workspace:*",
"@tsbb/db": "workspace:*",
"@tsbb/mail": "workspace:*",
"@tsbb/mcp": "workspace:*",
"@tsbb/plugin-host": "workspace:*"
}
}
Loading
Loading