@x4lt7ab/tab-for-projects 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +266 -0
- package/package.json +39 -0
- package/src/domain/args.ts +41 -0
- package/src/domain/bootstrap.ts +26 -0
- package/src/domain/db/connection.ts +21 -0
- package/src/domain/db/schema.ts +35 -0
- package/src/domain/entities.ts +21 -0
- package/src/domain/errors.ts +9 -0
- package/src/domain/index.ts +12 -0
- package/src/domain/inputs.ts +13 -0
- package/src/domain/repositories/projects.ts +75 -0
- package/src/domain/repositories/tasks.ts +62 -0
- package/src/domain/services/projects.ts +68 -0
- package/src/domain/services/tasks.ts +62 -0
- package/src/domain/services.ts +22 -0
- package/src/domain/statuses.ts +5 -0
- package/src/index.ts +4 -0
- package/src/mcp/index.ts +3 -0
- package/src/mcp/server.ts +140 -0
- package/src/mcp/standalone.ts +48 -0
- package/src/server/index.ts +102 -0
- package/src/server/routes/projects.ts +58 -0
- package/src/server/routes/tasks.ts +58 -0
- package/src/web/dist/assets/index-Bonqd4_2.js +49 -0
- package/src/web/dist/index.html +28 -0
- package/src/web/index.html +28 -0
- package/src/web/src/App.tsx +829 -0
- package/src/web/src/api.ts +1 -0
- package/src/web/src/components/Badge.tsx +54 -0
- package/src/web/src/components/Button.tsx +58 -0
- package/src/web/src/components/Card.tsx +40 -0
- package/src/web/src/components/Icon.tsx +22 -0
- package/src/web/src/components/IconButton.tsx +50 -0
- package/src/web/src/components/Input.tsx +47 -0
- package/src/web/src/components/Select.tsx +41 -0
- package/src/web/src/components/Stack.tsx +38 -0
- package/src/web/src/components/ThemeContext.tsx +53 -0
- package/src/web/src/components/ThemeSwitcher.tsx +49 -0
- package/src/web/src/components/TopBar.tsx +38 -0
- package/src/web/src/components/index.ts +12 -0
- package/src/web/src/components/theme.ts +185 -0
- package/src/web/src/main.tsx +12 -0
- package/src/web/tsconfig.json +17 -0
- package/src/web/vite.config.ts +16 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Project } from "../entities";
|
|
2
|
+
import type { CreateProjectInput, UpdateProjectInput } from "../inputs";
|
|
3
|
+
import type { IProjectService } from "../services";
|
|
4
|
+
import { ServiceError } from "../errors";
|
|
5
|
+
import type { ProjectRepository } from "../repositories/projects";
|
|
6
|
+
import { PROJECT_STATUSES } from "../statuses";
|
|
7
|
+
|
|
8
|
+
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
9
|
+
|
|
10
|
+
export class ProjectService implements IProjectService {
|
|
11
|
+
constructor(private repo: ProjectRepository) {}
|
|
12
|
+
|
|
13
|
+
findAll(): Project[] {
|
|
14
|
+
return this.repo.findAll();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
findBySlug(slug: string): Project | null {
|
|
18
|
+
return this.repo.findBySlug(slug);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
create(input: CreateProjectInput): Project {
|
|
22
|
+
if (!input.name?.trim()) {
|
|
23
|
+
throw new ServiceError("name is required", 400);
|
|
24
|
+
}
|
|
25
|
+
if (input.name.length > 255) {
|
|
26
|
+
throw new ServiceError("name must be 255 characters or fewer", 400);
|
|
27
|
+
}
|
|
28
|
+
if (!input.slug?.trim()) {
|
|
29
|
+
throw new ServiceError("slug is required", 400);
|
|
30
|
+
}
|
|
31
|
+
if (input.slug.length > 100) {
|
|
32
|
+
throw new ServiceError("slug must be 100 characters or fewer", 400);
|
|
33
|
+
}
|
|
34
|
+
if (!SLUG_RE.test(input.slug)) {
|
|
35
|
+
throw new ServiceError("slug must be lowercase alphanumeric with hyphens only", 400);
|
|
36
|
+
}
|
|
37
|
+
if (input.description !== undefined && input.description.length > 10000) {
|
|
38
|
+
throw new ServiceError("description must be 10000 characters or fewer", 400);
|
|
39
|
+
}
|
|
40
|
+
if (input.status !== undefined && !(PROJECT_STATUSES as readonly string[]).includes(input.status)) {
|
|
41
|
+
throw new ServiceError(`status must be one of: ${PROJECT_STATUSES.join(", ")}`, 400);
|
|
42
|
+
}
|
|
43
|
+
if (this.repo.findBySlug(input.slug)) {
|
|
44
|
+
throw new ServiceError("slug already exists", 409);
|
|
45
|
+
}
|
|
46
|
+
return this.repo.create(input);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
update(slug: string, input: UpdateProjectInput): Project | null {
|
|
50
|
+
if (input.name !== undefined && !input.name.trim()) {
|
|
51
|
+
throw new ServiceError("name cannot be empty", 400);
|
|
52
|
+
}
|
|
53
|
+
if (input.name !== undefined && input.name.length > 255) {
|
|
54
|
+
throw new ServiceError("name must be 255 characters or fewer", 400);
|
|
55
|
+
}
|
|
56
|
+
if (input.description !== undefined && input.description.length > 10000) {
|
|
57
|
+
throw new ServiceError("description must be 10000 characters or fewer", 400);
|
|
58
|
+
}
|
|
59
|
+
if (input.status !== undefined && !(PROJECT_STATUSES as readonly string[]).includes(input.status)) {
|
|
60
|
+
throw new ServiceError(`status must be one of: ${PROJECT_STATUSES.join(", ")}`, 400);
|
|
61
|
+
}
|
|
62
|
+
return this.repo.update(slug, input);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
delete(slug: string): boolean {
|
|
66
|
+
return this.repo.delete(slug);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Task } from "../entities";
|
|
2
|
+
import type { CreateTaskInput, UpdateTaskInput } from "../inputs";
|
|
3
|
+
import type { ITaskService } from "../services";
|
|
4
|
+
import { ServiceError } from "../errors";
|
|
5
|
+
import type { TaskRepository } from "../repositories/tasks";
|
|
6
|
+
import type { ProjectRepository } from "../repositories/projects";
|
|
7
|
+
import { TASK_STATUSES } from "../statuses";
|
|
8
|
+
|
|
9
|
+
export class TaskService implements ITaskService {
|
|
10
|
+
constructor(
|
|
11
|
+
private taskRepo: TaskRepository,
|
|
12
|
+
private projectRepo: ProjectRepository
|
|
13
|
+
) {}
|
|
14
|
+
|
|
15
|
+
findByProjectSlug(projectSlug: string): Task[] {
|
|
16
|
+
const project = this.projectRepo.findBySlug(projectSlug);
|
|
17
|
+
if (!project) {
|
|
18
|
+
throw new ServiceError("project not found", 404);
|
|
19
|
+
}
|
|
20
|
+
return this.taskRepo.findByProject(project.id);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
create(projectSlug: string, input: CreateTaskInput): Task {
|
|
24
|
+
const project = this.projectRepo.findBySlug(projectSlug);
|
|
25
|
+
if (!project) {
|
|
26
|
+
throw new ServiceError("project not found", 404);
|
|
27
|
+
}
|
|
28
|
+
if (!input.title?.trim()) {
|
|
29
|
+
throw new ServiceError("title is required", 400);
|
|
30
|
+
}
|
|
31
|
+
if (input.title.length > 500) {
|
|
32
|
+
throw new ServiceError("title must be 500 characters or fewer", 400);
|
|
33
|
+
}
|
|
34
|
+
if (input.description !== undefined && input.description.length > 10000) {
|
|
35
|
+
throw new ServiceError("description must be 10000 characters or fewer", 400);
|
|
36
|
+
}
|
|
37
|
+
if (input.status !== undefined && !(TASK_STATUSES as readonly string[]).includes(input.status)) {
|
|
38
|
+
throw new ServiceError(`status must be one of: ${TASK_STATUSES.join(", ")}`, 400);
|
|
39
|
+
}
|
|
40
|
+
return this.taskRepo.create(project.id, input);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
update(id: string, input: UpdateTaskInput): Task | null {
|
|
44
|
+
if (input.title !== undefined && !input.title.trim()) {
|
|
45
|
+
throw new ServiceError("title cannot be empty", 400);
|
|
46
|
+
}
|
|
47
|
+
if (input.title !== undefined && input.title.length > 500) {
|
|
48
|
+
throw new ServiceError("title must be 500 characters or fewer", 400);
|
|
49
|
+
}
|
|
50
|
+
if (input.description !== undefined && input.description.length > 10000) {
|
|
51
|
+
throw new ServiceError("description must be 10000 characters or fewer", 400);
|
|
52
|
+
}
|
|
53
|
+
if (input.status !== undefined && !(TASK_STATUSES as readonly string[]).includes(input.status)) {
|
|
54
|
+
throw new ServiceError(`status must be one of: ${TASK_STATUSES.join(", ")}`, 400);
|
|
55
|
+
}
|
|
56
|
+
return this.taskRepo.update(id, input);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
delete(id: string): boolean {
|
|
60
|
+
return this.taskRepo.delete(id);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Project, Task } from "./entities";
|
|
2
|
+
import type {
|
|
3
|
+
CreateProjectInput,
|
|
4
|
+
UpdateProjectInput,
|
|
5
|
+
CreateTaskInput,
|
|
6
|
+
UpdateTaskInput,
|
|
7
|
+
} from "./inputs";
|
|
8
|
+
|
|
9
|
+
export interface IProjectService {
|
|
10
|
+
findAll(): Project[];
|
|
11
|
+
findBySlug(slug: string): Project | null;
|
|
12
|
+
create(input: CreateProjectInput): Project;
|
|
13
|
+
update(slug: string, input: UpdateProjectInput): Project | null;
|
|
14
|
+
delete(slug: string): boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ITaskService {
|
|
18
|
+
findByProjectSlug(projectSlug: string): Task[];
|
|
19
|
+
create(projectSlug: string, input: CreateTaskInput): Task;
|
|
20
|
+
update(id: string, input: UpdateTaskInput): Task | null;
|
|
21
|
+
delete(id: string): boolean;
|
|
22
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export const PROJECT_STATUSES = ["active", "paused", "completed", "archived"] as const;
|
|
2
|
+
export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
|
|
3
|
+
|
|
4
|
+
export const TASK_STATUSES = ["todo", "in_progress", "done"] as const;
|
|
5
|
+
export type TaskStatus = (typeof TASK_STATUSES)[number];
|
package/src/index.ts
ADDED
package/src/mcp/index.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import {
|
|
5
|
+
ServiceError,
|
|
6
|
+
PROJECT_STATUSES,
|
|
7
|
+
TASK_STATUSES,
|
|
8
|
+
type IProjectService,
|
|
9
|
+
type ITaskService,
|
|
10
|
+
} from "../domain";
|
|
11
|
+
|
|
12
|
+
export interface McpServiceContext {
|
|
13
|
+
projectService: IProjectService;
|
|
14
|
+
taskService: ITaskService;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function handle<T>(fn: () => T) {
|
|
18
|
+
try {
|
|
19
|
+
const result = fn();
|
|
20
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
|
21
|
+
} catch (err) {
|
|
22
|
+
if (err instanceof ServiceError) {
|
|
23
|
+
return {
|
|
24
|
+
content: [{ type: "text" as const, text: err.message }],
|
|
25
|
+
isError: true,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
throw err;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Create an McpServer with all tools registered. */
|
|
33
|
+
export function createMcpServer(ctx: McpServiceContext): McpServer {
|
|
34
|
+
const { projectService, taskService } = ctx;
|
|
35
|
+
|
|
36
|
+
const server = new McpServer({
|
|
37
|
+
name: "tab-for-projects",
|
|
38
|
+
version: "0.1.0",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// ── Projects ──────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
server.registerTool("list_projects", { description: "List all projects" }, () =>
|
|
44
|
+
handle(() => projectService.findAll())
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
server.registerTool(
|
|
48
|
+
"get_project",
|
|
49
|
+
{ description: "Get a project by slug", inputSchema: { slug: z.string() } },
|
|
50
|
+
({ slug }) => handle(() => projectService.findBySlug(slug))
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
server.registerTool(
|
|
54
|
+
"create_project",
|
|
55
|
+
{
|
|
56
|
+
description: "Create a new project",
|
|
57
|
+
inputSchema: {
|
|
58
|
+
name: z.string(),
|
|
59
|
+
slug: z.string(),
|
|
60
|
+
description: z.string().optional(),
|
|
61
|
+
status: z.enum(PROJECT_STATUSES).optional(),
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
(input) => handle(() => projectService.create(input))
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
server.registerTool(
|
|
68
|
+
"update_project",
|
|
69
|
+
{
|
|
70
|
+
description: "Update an existing project",
|
|
71
|
+
inputSchema: {
|
|
72
|
+
slug: z.string(),
|
|
73
|
+
name: z.string().optional(),
|
|
74
|
+
description: z.string().optional(),
|
|
75
|
+
status: z.enum(PROJECT_STATUSES).optional(),
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
({ slug, ...updates }) => handle(() => projectService.update(slug, updates))
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
server.registerTool(
|
|
82
|
+
"delete_project",
|
|
83
|
+
{ description: "Delete a project by slug", inputSchema: { slug: z.string() } },
|
|
84
|
+
({ slug }) => handle(() => projectService.delete(slug))
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// ── Tasks ─────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
server.registerTool(
|
|
90
|
+
"list_tasks",
|
|
91
|
+
{ description: "List tasks for a project", inputSchema: { project_slug: z.string() } },
|
|
92
|
+
({ project_slug }) => handle(() => taskService.findByProjectSlug(project_slug))
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
server.registerTool(
|
|
96
|
+
"create_task",
|
|
97
|
+
{
|
|
98
|
+
description: "Create a task in a project",
|
|
99
|
+
inputSchema: {
|
|
100
|
+
project_slug: z.string(),
|
|
101
|
+
title: z.string(),
|
|
102
|
+
description: z.string().optional(),
|
|
103
|
+
status: z.enum(TASK_STATUSES).optional(),
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
({ project_slug, ...input }) => handle(() => taskService.create(project_slug, input))
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
server.registerTool(
|
|
110
|
+
"update_task",
|
|
111
|
+
{
|
|
112
|
+
description: "Update a task by ID",
|
|
113
|
+
inputSchema: {
|
|
114
|
+
id: z.string(),
|
|
115
|
+
title: z.string().optional(),
|
|
116
|
+
description: z.string().optional(),
|
|
117
|
+
status: z.enum(TASK_STATUSES).optional(),
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
({ id, ...updates }) => handle(() => taskService.update(id, updates))
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
server.registerTool(
|
|
124
|
+
"delete_task",
|
|
125
|
+
{ description: "Delete a task by ID", inputSchema: { id: z.string() } },
|
|
126
|
+
({ id }) => handle(() => taskService.delete(id))
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
return server;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Handle a single MCP-over-HTTP request (stateless, one server per request). */
|
|
133
|
+
export async function handleMcpHttp(ctx: McpServiceContext, req: Request): Promise<Response> {
|
|
134
|
+
const server = createMcpServer(ctx);
|
|
135
|
+
const transport = new WebStandardStreamableHTTPServerTransport();
|
|
136
|
+
await server.connect(transport);
|
|
137
|
+
const response = await transport.handleRequest(req);
|
|
138
|
+
await server.close();
|
|
139
|
+
return response;
|
|
140
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { cors } from "hono/cors";
|
|
4
|
+
import { bootstrap } from "../domain";
|
|
5
|
+
import { parseArgs, logListening, type ServerOptions } from "../domain/args";
|
|
6
|
+
import { handleMcpHttp } from "./server";
|
|
7
|
+
|
|
8
|
+
export class McpStandaloneServer {
|
|
9
|
+
private options: ServerOptions;
|
|
10
|
+
|
|
11
|
+
constructor(options?: Partial<ServerOptions>) {
|
|
12
|
+
const defaults = parseArgs({ port: 3001, portEnv: "PM_MCP_PORT" });
|
|
13
|
+
this.options = { ...defaults, ...options };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
start(): void {
|
|
17
|
+
const { port, host, dbPath } = this.options;
|
|
18
|
+
const ctx = bootstrap(dbPath);
|
|
19
|
+
|
|
20
|
+
const app = new Hono();
|
|
21
|
+
app.use(
|
|
22
|
+
"*",
|
|
23
|
+
cors({
|
|
24
|
+
origin: (origin) =>
|
|
25
|
+
origin?.startsWith("http://localhost") || origin?.startsWith("http://127.0.0.1")
|
|
26
|
+
? origin
|
|
27
|
+
: null,
|
|
28
|
+
allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
|
|
29
|
+
allowHeaders: ["Content-Type", "mcp-session-id", "Last-Event-ID", "mcp-protocol-version"],
|
|
30
|
+
exposeHeaders: ["mcp-session-id", "mcp-protocol-version"],
|
|
31
|
+
})
|
|
32
|
+
);
|
|
33
|
+
app.all("/*", (c) => handleMcpHttp(ctx, c.req.raw));
|
|
34
|
+
|
|
35
|
+
logListening("tab-for-projects mcp (standalone)", host, port);
|
|
36
|
+
|
|
37
|
+
Bun.serve({
|
|
38
|
+
port,
|
|
39
|
+
hostname: host,
|
|
40
|
+
fetch: app.fetch,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Direct execution
|
|
46
|
+
if (import.meta.main) {
|
|
47
|
+
new McpStandaloneServer().start();
|
|
48
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { logger } from "hono/logger";
|
|
4
|
+
import { cors } from "hono/cors";
|
|
5
|
+
import { serveStatic } from "hono/bun";
|
|
6
|
+
import { compress } from "hono/compress";
|
|
7
|
+
import { secureHeaders } from "hono/secure-headers";
|
|
8
|
+
import { etag } from "hono/etag";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { readFileSync, existsSync } from "fs";
|
|
11
|
+
import { bootstrap, ServiceError } from "../domain";
|
|
12
|
+
import { parseArgs, logListening, type ServerOptions } from "../domain/args";
|
|
13
|
+
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
|
14
|
+
import { projectRoutes } from "./routes/projects";
|
|
15
|
+
import { taskRoutes } from "./routes/tasks";
|
|
16
|
+
import { handleMcpHttp } from "../mcp/server";
|
|
17
|
+
|
|
18
|
+
export class Server {
|
|
19
|
+
private options: ServerOptions;
|
|
20
|
+
|
|
21
|
+
constructor(options?: Partial<ServerOptions>) {
|
|
22
|
+
const defaults = parseArgs({ port: 3000, portEnv: "PM_PORT" });
|
|
23
|
+
this.options = { ...defaults, ...options };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
start(): void {
|
|
27
|
+
const { port, host, dbPath } = this.options;
|
|
28
|
+
const ctx = bootstrap(dbPath);
|
|
29
|
+
|
|
30
|
+
const app = new Hono();
|
|
31
|
+
|
|
32
|
+
// ── Global middleware ──────────────────────────────────
|
|
33
|
+
app.use("*", secureHeaders());
|
|
34
|
+
app.use("*", compress());
|
|
35
|
+
app.use(
|
|
36
|
+
"*",
|
|
37
|
+
cors({
|
|
38
|
+
origin: (origin) =>
|
|
39
|
+
origin?.startsWith("http://localhost") || origin?.startsWith("http://127.0.0.1")
|
|
40
|
+
? origin
|
|
41
|
+
: null,
|
|
42
|
+
allowMethods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
|
43
|
+
allowHeaders: ["Content-Type", "mcp-session-id", "Last-Event-ID", "mcp-protocol-version"],
|
|
44
|
+
exposeHeaders: ["mcp-session-id", "mcp-protocol-version"],
|
|
45
|
+
})
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// ── API (with logging) ────────────────────────────────
|
|
49
|
+
app.use("/api/*", logger((str) => process.stderr.write(str + "\n")));
|
|
50
|
+
app.route("/api/projects/:projectSlug/tasks", taskRoutes(ctx.taskService));
|
|
51
|
+
app.route("/api/projects", projectRoutes(ctx.projectService));
|
|
52
|
+
app.get("/api/health", (c) => c.json({ status: "ok" }));
|
|
53
|
+
|
|
54
|
+
// ── MCP (no logging) ──────────────────────────────────
|
|
55
|
+
app.all("/mcp", (c) => handleMcpHttp(ctx, c.req.raw));
|
|
56
|
+
|
|
57
|
+
// ── Static web assets ─────────────────────────────────
|
|
58
|
+
const dist = join(import.meta.dir, "../web/dist");
|
|
59
|
+
const indexPath = join(dist, "index.html");
|
|
60
|
+
const indexHtml = existsSync(indexPath)
|
|
61
|
+
? readFileSync(indexPath, "utf-8")
|
|
62
|
+
: null;
|
|
63
|
+
|
|
64
|
+
app.use(
|
|
65
|
+
"/assets/*",
|
|
66
|
+
etag(),
|
|
67
|
+
async (c, next) => {
|
|
68
|
+
await next();
|
|
69
|
+
c.header("Cache-Control", "public, max-age=31536000, immutable");
|
|
70
|
+
}
|
|
71
|
+
);
|
|
72
|
+
app.use("/*", etag());
|
|
73
|
+
app.use("/*", serveStatic({ root: dist }));
|
|
74
|
+
|
|
75
|
+
// SPA fallback
|
|
76
|
+
app.get("/*", (c) => {
|
|
77
|
+
if (indexHtml) return c.html(indexHtml);
|
|
78
|
+
return c.text("Not found — run `bun run build` first", 404);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ── Error handling ────────────────────────────────────
|
|
82
|
+
app.onError((err, c) => {
|
|
83
|
+
if (err instanceof SyntaxError) return c.json({ error: "invalid JSON body" }, 400);
|
|
84
|
+
if (err instanceof ServiceError) return c.json({ error: err.message }, err.statusCode as ContentfulStatusCode);
|
|
85
|
+
console.error(err);
|
|
86
|
+
return c.json({ error: "internal server error" }, 500);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
logListening("tab-for-projects", host, port);
|
|
90
|
+
|
|
91
|
+
Bun.serve({
|
|
92
|
+
port,
|
|
93
|
+
hostname: host,
|
|
94
|
+
fetch: app.fetch,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Direct execution
|
|
100
|
+
if (import.meta.main) {
|
|
101
|
+
new Server().start();
|
|
102
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
|
3
|
+
import {
|
|
4
|
+
ServiceError,
|
|
5
|
+
type IProjectService,
|
|
6
|
+
type CreateProjectInput,
|
|
7
|
+
type UpdateProjectInput,
|
|
8
|
+
} from "../../domain";
|
|
9
|
+
|
|
10
|
+
export function projectRoutes(service: IProjectService): Hono {
|
|
11
|
+
const app = new Hono();
|
|
12
|
+
|
|
13
|
+
// GET /api/projects
|
|
14
|
+
app.get("/", (c) => {
|
|
15
|
+
return c.json(service.findAll());
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// POST /api/projects
|
|
19
|
+
app.post("/", async (c) => {
|
|
20
|
+
const body = await c.req.json<CreateProjectInput>();
|
|
21
|
+
try {
|
|
22
|
+
const project = service.create(body);
|
|
23
|
+
return c.json(project, 201);
|
|
24
|
+
} catch (e: unknown) {
|
|
25
|
+
if (e instanceof ServiceError) return c.json({ error: e.message }, e.statusCode as ContentfulStatusCode);
|
|
26
|
+
throw e;
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// GET /api/projects/:slug
|
|
31
|
+
app.get("/:slug", (c) => {
|
|
32
|
+
const project = service.findBySlug(c.req.param("slug"));
|
|
33
|
+
if (!project) return c.json({ error: "project not found" }, 404);
|
|
34
|
+
return c.json(project);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// PATCH /api/projects/:slug
|
|
38
|
+
app.patch("/:slug", async (c) => {
|
|
39
|
+
const body = await c.req.json<UpdateProjectInput>();
|
|
40
|
+
try {
|
|
41
|
+
const project = service.update(c.req.param("slug"), body);
|
|
42
|
+
if (!project) return c.json({ error: "project not found" }, 404);
|
|
43
|
+
return c.json(project);
|
|
44
|
+
} catch (e: unknown) {
|
|
45
|
+
if (e instanceof ServiceError) return c.json({ error: e.message }, e.statusCode as ContentfulStatusCode);
|
|
46
|
+
throw e;
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// DELETE /api/projects/:slug
|
|
51
|
+
app.delete("/:slug", (c) => {
|
|
52
|
+
const deleted = service.delete(c.req.param("slug"));
|
|
53
|
+
if (!deleted) return c.json({ error: "project not found" }, 404);
|
|
54
|
+
return c.json({ ok: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return app;
|
|
58
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
|
3
|
+
import {
|
|
4
|
+
ServiceError,
|
|
5
|
+
type ITaskService,
|
|
6
|
+
type CreateTaskInput,
|
|
7
|
+
type UpdateTaskInput,
|
|
8
|
+
} from "../../domain";
|
|
9
|
+
|
|
10
|
+
export function taskRoutes(service: ITaskService): Hono {
|
|
11
|
+
const app = new Hono();
|
|
12
|
+
|
|
13
|
+
// GET /api/projects/:projectSlug/tasks
|
|
14
|
+
app.get("/", (c) => {
|
|
15
|
+
const projectSlug = c.req.param("projectSlug")!;
|
|
16
|
+
try {
|
|
17
|
+
return c.json(service.findByProjectSlug(projectSlug));
|
|
18
|
+
} catch (e: unknown) {
|
|
19
|
+
if (e instanceof ServiceError) return c.json({ error: e.message }, e.statusCode as ContentfulStatusCode);
|
|
20
|
+
throw e;
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// POST /api/projects/:projectSlug/tasks
|
|
25
|
+
app.post("/", async (c) => {
|
|
26
|
+
const projectSlug = c.req.param("projectSlug")!;
|
|
27
|
+
const body = await c.req.json<CreateTaskInput>();
|
|
28
|
+
try {
|
|
29
|
+
const task = service.create(projectSlug, body);
|
|
30
|
+
return c.json(task, 201);
|
|
31
|
+
} catch (e: unknown) {
|
|
32
|
+
if (e instanceof ServiceError) return c.json({ error: e.message }, e.statusCode as ContentfulStatusCode);
|
|
33
|
+
throw e;
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// PATCH /api/projects/:projectSlug/tasks/:id
|
|
38
|
+
app.patch("/:id", async (c) => {
|
|
39
|
+
const body = await c.req.json<UpdateTaskInput>();
|
|
40
|
+
try {
|
|
41
|
+
const task = service.update(c.req.param("id")!, body);
|
|
42
|
+
if (!task) return c.json({ error: "task not found" }, 404);
|
|
43
|
+
return c.json(task);
|
|
44
|
+
} catch (e: unknown) {
|
|
45
|
+
if (e instanceof ServiceError) return c.json({ error: e.message }, e.statusCode as ContentfulStatusCode);
|
|
46
|
+
throw e;
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// DELETE /api/projects/:projectSlug/tasks/:id
|
|
51
|
+
app.delete("/:id", (c) => {
|
|
52
|
+
const deleted = service.delete(c.req.param("id")!);
|
|
53
|
+
if (!deleted) return c.json({ error: "task not found" }, 404);
|
|
54
|
+
return c.json({ ok: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return app;
|
|
58
|
+
}
|