Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
8704cc2
Add MIT license and fix docs footer links for GitHub Pages basePath
nathanialhenniges Feb 14, 2026
cb2ba75
Add GitHub Sponsors funding configuration
nathanialhenniges Feb 18, 2026
265c737
Merge pull request #4 from MrDemonWolf/DW-2/configure-github-repo-set…
nathanialhenniges Feb 18, 2026
e4068fa
Add .claude directory to .gitignore
nathanialhenniges Feb 19, 2026
e081b08
chore: add FUNDING.yml for GitHub Sponsors
nathanialhenniges Feb 20, 2026
c689048
chore: fix FUNDING.yml sponsor username
nathanialhenniges Feb 20, 2026
fcd40c9
Fix deployment: switch to Dockerfile builds with resilient entrypoint
nathanialhenniges Feb 20, 2026
b1271da
Merge main into dev
nathanialhenniges Feb 20, 2026
e2b02bb
Merge branch 'main' into dev
nathanialhenniges Feb 20, 2026
3ce54c0
Merge branch 'main' into dev
nathanialhenniges Feb 20, 2026
b5e8d19
Merge branch 'main' into dev
nathanialhenniges Feb 20, 2026
69b15d7
Merge branch 'main' into dev
nathanialhenniges Feb 20, 2026
61d6e1e
Add cursor-not-allowed feedback on disabled UI elements and refactor …
nathanialhenniges Feb 24, 2026
5930f56
Add blur-to-reveal toggle for overlay URLs on dashboard
nathanialhenniges Feb 26, 2026
f97d6ce
Add Twitch bot runtime, active task state, and dashboard improvements
nathanialhenniges Mar 1, 2026
70e4604
Add !dwhelp/!dwcommands bot commands and update chat-commands docs
nathanialhenniges Mar 1, 2026
702d843
Migrate to Bun/Drizzle, add bot runtime, and refactor bot settings la…
nathanialhenniges Mar 13, 2026
853bb98
Refactor bot settings UI and add bot command tests
nathanialhenniges Mar 14, 2026
018af7f
Add security hardening, test coverage, and deployment docs
nathanialhenniges Mar 15, 2026
5d0dac5
Update lockfile after adding test script to auth package
nathanialhenniges Mar 15, 2026
a832212
Merge branch 'main' into dev
nathanialhenniges Mar 18, 2026
d9eeddb
Update README intro and make license badge clickable
nathanialhenniges Mar 18, 2026
947e6d6
Bot Settings UI Refactor & SEO Improvements (#6)
nathanialhenniges Mar 26, 2026
9e36a3f
fix: address CodeRabbit review findings from PR #5
nathanialhenniges Mar 26, 2026
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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ Deployed via **Coolify** using **Dockerfile**. Config in `Dockerfile` + `docker-

- Next.js uses `output: "standalone"` for containerized deployment
- `SKIP_ENV_VALIDATION=true` is set at build time to bypass t3-env validation (runtime secrets aren't available during build)
- Docker build: install deps → generate Prisma client → build Next.js → copy static assets
- Docker build: install deps → build Drizzle migration bundle → build Next.js → copy static assets
- Start command: `node apps/web/.next/standalone/apps/web/server.js`
- PostgreSQL 17 Alpine as a separate Coolify service
- Instance-specific notes live in `coolify.md` (gitignored)
Expand Down
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ COPY --from=build /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=build /app/packages/db/migrate.js ./packages/db/migrate.js
COPY --from=build /app/packages/db/drizzle ./packages/db/drizzle
COPY docker-entrypoint.sh ./
RUN chown -R node:node /app && chmod +x /app/docker-entrypoint.sh
USER node

EXPOSE 3000
ENTRYPOINT ["./docker-entrypoint.sh"]
40 changes: 21 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
# Dirework - Pomodoro Timer and Task List for Twitch

Self-hosted Pomodoro timer and task list built for Twitch
co-working and body-doubling streams. Your viewers join the
grind through chat commands while customizable OBS overlays
keep everyone focused and in sync.
Dirework is a personal project built for my own Twitch
co-working and body-doubling streams. It combines a
Pomodoro timer, viewer task list, and Twitch chat bot into
a single self-hosted tool with customizable OBS overlays.

One streamer, one instance, zero distractions.
It is open source. If you want the same setup for your own
channel, fork it and run your own instance — one streamer,
one instance, zero distractions.

## Features

Expand Down Expand Up @@ -104,7 +106,7 @@ command aliases.
### Prerequisites

- Node.js 20+
- pnpm 10+
- Bun 1.0+
- Docker (for PostgreSQL)
- A Twitch Developer Application
([dev.twitch.tv](https://dev.twitch.tv/console))
Expand All @@ -121,7 +123,7 @@ command aliases.
2. Install dependencies:

```bash
pnpm install
bun install
```

3. Configure environment variables in `apps/web/.env`:
Expand All @@ -138,30 +140,30 @@ command aliases.
4. Start the database:

```bash
pnpm db:start
bun db:start
```

5. Push the schema:

```bash
pnpm db:push
bun db:push
```

6. Start the dev server:

```bash
pnpm dev
bun dev
```

### Development Scripts

- `pnpm dev` - Start all apps (web on port 3001, docs on port 4000)
- `pnpm build` - Build all apps for production
- `pnpm check-types` - Run TypeScript type checking
- `pnpm test` - Run unit tests across all packages
- `pnpm dev:web` - Start the web app only
- `pnpm db:start` - Start PostgreSQL via Docker
- `pnpm db:stop` - Stop PostgreSQL
- `bun dev` - Start all apps (web on port 3001, docs on port 4000)
- `bun build` - Build all apps for production
- `bun check-types` - Run TypeScript type checking
- `bun test` - Run unit tests across all packages
- `bun dev:web` - Start the web app only
- `bun db:start` - Start PostgreSQL via Docker
- `bun db:stop` - Stop PostgreSQL
- `bun run db:push` - Push Drizzle schema to database (dev only, no migration file)
- `bun run db:generate` - Generate a new Drizzle migration from schema changes
- `bun run db:migrate` - Apply pending Drizzle migrations
Expand All @@ -173,7 +175,7 @@ command aliases.
- Tests cover timer state machine, config build/flatten helpers,
round-trip consistency, display utilities, task grouping, and
event emitter isolation
- Run with `pnpm test`
- Run with `bun test`

### Code Quality

Expand Down Expand Up @@ -201,7 +203,7 @@ dirework/

## License

![GitHub license](https://img.shields.io/github/license/mrdemonwolf/dirework.svg?style=for-the-badge&logo=github)
[![GitHub license](https://img.shields.io/github/license/MrDemonWolf/dirework.svg?style=for-the-badge&logo=github)](https://github.com/MrDemonWolf/dirework/blob/main/LICENSE)

## Contact

Expand Down
4 changes: 2 additions & 2 deletions apps/web/instrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ export async function register() {
const { botService } = await import("@dirework/api/bot/index");
const { logger } = await import("@dirework/api/logger");

// Find a bot account to auto-start
// Find a bot account to auto-start (single-user-per-instance app)
const botAccount = await db.query.botAccount.findFirst({
columns: { userId: true },
});

if (botAccount && !botService.isRunning()) {
await botService.start(db, botAccount.userId);
logger.info("[Instrumentation] Bot auto-started");
logger.info(`[Instrumentation] Bot auto-started for user ${botAccount.userId}`);
}
} catch (err) {
const { logger } = await import("@dirework/api/logger").catch(() => ({
Expand Down
16 changes: 13 additions & 3 deletions apps/web/src/app/(app)/api/bot/authorize/route.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomBytes } from "node:crypto";
import { auth } from "@dirework/auth";
import { env } from "@dirework/env/server";
import { headers } from "next/headers";
Expand All @@ -12,18 +13,27 @@ export async function GET() {
return NextResponse.redirect(new URL("/?error=not_authenticated", env.BETTER_AUTH_URL));
}

const nonce = randomBytes(32).toString("hex");
const state = Buffer.from(
JSON.stringify({ userId: session.user.id }),
JSON.stringify({ userId: session.user.id, nonce }),
).toString("base64url");

const params = new URLSearchParams({
client_id: env.TWITCH_CLIENT_ID,
redirect_uri: `${env.BETTER_AUTH_URL}/api/bot/callback/twitch`,
response_type: "code",
scope: "chat:read chat:edit channel:moderate user:read:chat",
scope: "user:read:chat user:write:chat",
force_verify: "true",
state,
});

return NextResponse.redirect(`https://id.twitch.tv/oauth2/authorize?${params}`);
const response = NextResponse.redirect(`https://id.twitch.tv/oauth2/authorize?${params}`);
response.cookies.set("bot_oauth_nonce", nonce, {
httpOnly: true,
secure: env.BETTER_AUTH_URL.startsWith("https"),
sameSite: "lax",
path: "/api/bot/callback",
maxAge: 300,
});
return response;
}
12 changes: 11 additions & 1 deletion apps/web/src/app/(app)/api/bot/callback/twitch/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { timingSafeEqual } from "node:crypto";
import { auth } from "@dirework/auth";
import { db } from "@dirework/db";
import * as schema from "@dirework/db/schema";
import { env } from "@dirework/env/server";
import { logger } from "@dirework/api/logger";
import { headers } from "next/headers";
import { cookies, headers } from "next/headers";
import { type NextRequest, NextResponse } from "next/server";

function errorRedirect(_request: NextRequest, reason: string) {
Expand All @@ -21,15 +22,24 @@ export async function GET(request: NextRequest) {
}

let userId: string;
let nonce: string;
try {
const decoded = JSON.parse(
Buffer.from(state, "base64url").toString(),
);
userId = decoded.userId;
nonce = decoded.nonce;
} catch {
return errorRedirect(request, "Invalid state parameter");
}

// Verify CSRF nonce from httpOnly cookie
const cookieStore = await cookies();
const storedNonce = cookieStore.get("bot_oauth_nonce")?.value;
if (!storedNonce || !nonce || !timingSafeEqual(Buffer.from(storedNonce), Buffer.from(nonce))) {
return errorRedirect(request, "Invalid or expired OAuth state — please try again");
}

// Verify the current session matches the userId from state
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.id !== userId) {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/(app)/dashboard/bot/bot-settings-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ export default function BotSettingsPage() {
toast.error(`Failed to connect bot account: ${reason}`);
router.replace("/dashboard/bot");
}
}, [searchParams]);
}, [searchParams, router]);

// Working state
const [taskCommandsEnabled, setTaskCommandsEnabled] = useState(true);
Expand Down
2 changes: 2 additions & 0 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#!/bin/sh

export SKIP_ENV_VALIDATION=true

echo "Running database migrations..."
if node /app/packages/db/migrate.js; then
echo "Database migrations completed successfully."
Expand Down
8 changes: 4 additions & 4 deletions packages/api/src/bot/commands.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { eq, and, asc, inArray, sql } from "drizzle-orm";
import { eq, and, asc, desc, inArray, sql } from "drizzle-orm";
import type { DbClient } from "@dirework/db";
import * as schema from "@dirework/db/schema";
import { env } from "@dirework/env/server";
Expand Down Expand Up @@ -183,7 +183,7 @@ async function handleTaskAdd(args: string[], ctx: MessageContext): Promise<void>

const lastTask = await db.query.task.findFirst({
where: and(eq(schema.task.ownerId, ownerId), eq(schema.task.priority, isBroadcaster ? 0 : 1)),
orderBy: [asc(schema.task.order)],
orderBy: [desc(schema.task.order)],
columns: { order: true },
});

Expand Down Expand Up @@ -468,7 +468,7 @@ async function handleTaskNext(args: string[], ctx: MessageContext): Promise<void

const lastTask = await db.query.task.findFirst({
where: and(eq(schema.task.ownerId, ownerId), eq(schema.task.priority, isBroadcaster ? 0 : 1)),
orderBy: [asc(schema.task.order)],
orderBy: [desc(schema.task.order)],
columns: { order: true },
});

Expand Down Expand Up @@ -697,7 +697,7 @@ async function handleTimerCommand(args: string[], ctx: MessageContext): Promise<
}

const etaDate = new Date(Date.now() + totalMs);
const timeStr = etaDate.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
const timeStr = etaDate.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });

say(interpolate(config.timer.eta, { ...vars, time: timeStr }));
break;
Expand Down
6 changes: 5 additions & 1 deletion packages/api/src/bot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ class TwitchBotService {
.set({
accessToken: tokenData.accessToken,
refreshToken: tokenData.refreshToken ?? botAccount.refreshToken,
expiresAt: tokenData.expiresIn
expiresAt: tokenData.expiresIn != null
? new Date(Date.now() + tokenData.expiresIn * 1000)
: botAccount.expiresAt,
scopes: tokenData.scope ?? botAccount.scopes,
Expand Down Expand Up @@ -181,6 +181,10 @@ class TwitchBotService {
return this.chatClient !== null;
}

getOwnerId(): string | null {
return this.userId;
}

getStatus(): { running: boolean; channel: string | null; botUsername: string | null } {
return {
running: this.isRunning(),
Expand Down
1 change: 1 addition & 0 deletions packages/api/src/routers/__tests__/timer-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ describe("getTimerConfig", () => {
longBreakInterval: 2,
startingDuration: 10000,
noLastBreak: false,
defaultCycles: 8,
};
const result = getTimerConfig(custom);
expect(result).toEqual(custom);
Expand Down
6 changes: 5 additions & 1 deletion packages/api/src/routers/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,15 @@ export const botRouter = router({
return botService.getStatus();
}),

stop: protectedProcedure.mutation(async () => {
stop: protectedProcedure.mutation(async ({ ctx }) => {
if (!botService.isRunning()) {
throw new TRPCError({ code: "CONFLICT", message: "Bot is not running" });
}

if (botService.getOwnerId() !== ctx.session.user.id) {
throw new TRPCError({ code: "FORBIDDEN", message: "You can only stop your own bot instance" });
}

await botService.stop();
return botService.getStatus();
}),
Expand Down
12 changes: 9 additions & 3 deletions packages/api/src/routers/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,11 +378,12 @@ export const configRouter = router({
}))
.mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
await ensureUserConfig(ctx.db, userId);
const [updated] = await ctx.db.update(schema.timerConfig)
.set(input)
.where(eq(schema.timerConfig.userId, userId))
.returning();
if (!updated) throw new TRPCError({ code: "NOT_FOUND", message: "Config row not found — call config.get first to provision defaults" });
if (!updated) throw new TRPCError({ code: "NOT_FOUND", message: "Config row not found" });
ee.emit(`timerStateChange:${userId}`);
return buildTimerConfig(updated);
}),
Expand All @@ -392,12 +393,13 @@ export const configRouter = router({
.input(z.object({ timerStyles: timerStylesSchema }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
await ensureUserConfig(ctx.db, userId);
const flat = flattenTimerStyles(input.timerStyles);
const [updated] = await ctx.db.update(schema.timerStyle)
.set(flat)
.where(eq(schema.timerStyle.userId, userId))
.returning();
if (!updated) throw new TRPCError({ code: "NOT_FOUND", message: "Config row not found — call config.get first to provision defaults" });
if (!updated) throw new TRPCError({ code: "NOT_FOUND", message: "Config row not found" });
ee.emit(`timerStateChange:${userId}`);
return buildTimerStylesConfig(updated);
}),
Expand All @@ -407,12 +409,13 @@ export const configRouter = router({
.input(z.object({ taskStyles: taskStylesSchema }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
await ensureUserConfig(ctx.db, userId);
const flat = flattenTaskStyles(input.taskStyles);
const [updated] = await ctx.db.update(schema.taskStyle)
.set(flat)
.where(eq(schema.taskStyle.userId, userId))
.returning();
if (!updated) throw new TRPCError({ code: "NOT_FOUND", message: "Config row not found — call config.get first to provision defaults" });
if (!updated) throw new TRPCError({ code: "NOT_FOUND", message: "Config row not found" });
ee.emit(`taskListChange:${userId}`);
return buildTaskStylesConfig(updated);
}),
Expand Down Expand Up @@ -463,6 +466,7 @@ export const configRouter = router({
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
await ensureUserConfig(ctx.db, userId);
const [result] = await ctx.db.update(schema.botConfig)
.set({
taskCommandsEnabled: input.taskCommandsEnabled,
Expand Down Expand Up @@ -521,6 +525,7 @@ export const configRouter = router({
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
await ensureUserConfig(ctx.db, userId);
const [result] = await ctx.db.update(schema.timerConfig)
.set({
labelIdle: input.idle,
Expand All @@ -547,6 +552,7 @@ export const configRouter = router({
}))
.mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
await ensureUserConfig(ctx.db, userId);
const [result] = await ctx.db.update(schema.botConfig)
.set({ commandAliases: input.commandAliases })
.where(eq(schema.botConfig.userId, userId))
Expand Down
Loading
Loading