yarlo-plugin-board 0.1.2 → 0.2.1

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.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../source/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE/C,QAAA,MAAM,MAAM,EAAE,WAcb,CAAC;AAEF,eAAe,MAAM,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../source/index.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AA6C/C,QAAA,MAAM,MAAM,EAAE,WAwCb,CAAC;AAEF,eAAe,MAAM,CAAC"}
package/dist/index.js CHANGED
@@ -1,14 +1,75 @@
1
+ import { spawn } from "node:child_process";
2
+ import { serve } from "@hono/node-server";
3
+ import { createBoardApp } from "./board-server.js";
4
+ const DEFAULT_PORT = 3847;
5
+ /**
6
+ * Parses `--port <n>` from plugin CLI args.
7
+ *
8
+ * @param args - Arguments after `yarlo board`
9
+ * @returns Valid TCP port
10
+ */
11
+ function parsePort(args) {
12
+ const idx = args.indexOf("--port");
13
+ if (idx >= 0 && args[idx + 1]) {
14
+ const n = Number.parseInt(args[idx + 1], 10);
15
+ if (!Number.isNaN(n) && n > 0 && n < 65536) {
16
+ return n;
17
+ }
18
+ }
19
+ return DEFAULT_PORT;
20
+ }
21
+ /**
22
+ * Opens the given URL in the system default browser.
23
+ *
24
+ * @param url - HTTP URL to open
25
+ */
26
+ function openBrowser(url) {
27
+ const platform = process.platform;
28
+ if (platform === "darwin") {
29
+ spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
30
+ return;
31
+ }
32
+ if (platform === "win32") {
33
+ spawn("cmd", ["/c", "start", "", url], {
34
+ detached: true,
35
+ stdio: "ignore",
36
+ windowsHide: true,
37
+ }).unref();
38
+ return;
39
+ }
40
+ spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
41
+ }
1
42
  const plugin = {
2
43
  name: "board",
3
- version: "0.1.2",
44
+ version: "0.2.1",
4
45
  description: "Kanban board view for yarlo tasks",
5
46
  commands: [
6
47
  {
7
48
  name: "board",
8
49
  description: "Open the task board in your browser",
9
- async run(_args, _ctx) {
10
- console.log("Board plugin - coming soon!");
11
- console.log("This will open a local web UI for managing tasks.");
50
+ async run(args, ctx) {
51
+ const port = parsePort(args);
52
+ const app = createBoardApp(ctx);
53
+ const server = serve({
54
+ fetch: app.fetch,
55
+ port,
56
+ hostname: "127.0.0.1",
57
+ }, (info) => {
58
+ const url = `http://127.0.0.1:${info.port}/`;
59
+ console.log(`Board running at ${url}`);
60
+ console.log("Press Ctrl+C to stop.");
61
+ openBrowser(url);
62
+ });
63
+ const shutdown = () => {
64
+ server.close(() => {
65
+ process.exit(0);
66
+ });
67
+ };
68
+ process.once("SIGINT", shutdown);
69
+ process.once("SIGTERM", shutdown);
70
+ await new Promise(() => {
71
+ /* keep process alive until signals */
72
+ });
12
73
  },
13
74
  },
14
75
  ],
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../source/index.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,GAAgB;IAC1B,IAAI,EAAE,OAAO;IACb,OAAO,EAAE,OAAO;IAChB,WAAW,EAAE,mCAAmC;IAChD,QAAQ,EAAE;QACR;YACE,IAAI,EAAE,OAAO;YACb,WAAW,EAAE,qCAAqC;YAClD,KAAK,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI;gBACnB,OAAO,CAAC,GAAG,CAAC,6BAA6B,CAAC,CAAC;gBAC3C,OAAO,CAAC,GAAG,CAAC,mDAAmD,CAAC,CAAC;YACnE,CAAC;SACF;KACF;CACF,CAAC;AAEF,eAAe,MAAM,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../source/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,9 @@
1
+ import type { YarloConfig } from "yarlo-types";
2
+ /**
3
+ * Returns ordered status column keys for the Kanban board from `config.fields`.
4
+ *
5
+ * @param config - Loaded yarlo project configuration
6
+ * @returns Option list for the `status` single_select field, or sensible defaults
7
+ */
8
+ export declare function getStatusColumnOptions(config: YarloConfig): string[];
9
+ //# sourceMappingURL=status-field.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"status-field.d.ts","sourceRoot":"","sources":["../source/status-field.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAU/C;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,EAAE,CAWpE"}
@@ -0,0 +1,23 @@
1
+ const DEFAULT_STATUS_OPTIONS = [
2
+ "backlog",
3
+ "todo",
4
+ "in_progress",
5
+ "done",
6
+ "cancelled",
7
+ ];
8
+ /**
9
+ * Returns ordered status column keys for the Kanban board from `config.fields`.
10
+ *
11
+ * @param config - Loaded yarlo project configuration
12
+ * @returns Option list for the `status` single_select field, or sensible defaults
13
+ */
14
+ export function getStatusColumnOptions(config) {
15
+ const def = config.fields.find((f) => f.name === "status");
16
+ if (def?.type === "single_select" &&
17
+ Array.isArray(def.options) &&
18
+ def.options.length > 0) {
19
+ return [...def.options];
20
+ }
21
+ return [...DEFAULT_STATUS_OPTIONS];
22
+ }
23
+ //# sourceMappingURL=status-field.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"status-field.js","sourceRoot":"","sources":["../source/status-field.ts"],"names":[],"mappings":"AAEA,MAAM,sBAAsB,GAAG;IAC7B,SAAS;IACT,MAAM;IACN,aAAa;IACb,MAAM;IACN,WAAW;CACH,CAAC;AAEX;;;;;GAKG;AACH,MAAM,UAAU,sBAAsB,CAAC,MAAmB;IACxD,MAAM,GAAG,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC;IAC3D,IACE,GAAG,EAAE,IAAI,KAAK,eAAe;QAC7B,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC;QAC1B,GAAG,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC,EACtB,CAAC;QACD,OAAO,CAAC,GAAG,GAAG,CAAC,OAAO,CAAC,CAAC;IAC1B,CAAC;IAED,OAAO,CAAC,GAAG,sBAAsB,CAAC,CAAC;AACrC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yarlo-plugin-board",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -12,14 +12,17 @@
12
12
  }
13
13
  },
14
14
  "dependencies": {
15
+ "@hono/node-server": "^1.13.0",
15
16
  "hono": "^4.7.0",
16
- "yarlo-types": "0.1.2"
17
+ "zod": "^3.24.0",
18
+ "yarlo-types": "0.1.3"
17
19
  },
18
20
  "devDependencies": {
21
+ "@types/node": "^22.0.0",
19
22
  "typescript": "^5.7.0"
20
23
  },
21
24
  "scripts": {
22
- "build": "tsc",
25
+ "build": "tsc && cp source/board-ui.html dist/board-ui.html",
23
26
  "dev": "tsc --watch"
24
27
  }
25
28
  }
@@ -0,0 +1,164 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type {
3
+ Task,
4
+ UpdateTaskInput,
5
+ YarloConfig,
6
+ YarloPluginContext,
7
+ } from "yarlo-types";
8
+ import { DEFAULT_CONFIG } from "yarlo-types";
9
+ import { createBoardApp } from "./board-server.js";
10
+
11
+ /**
12
+ * Minimal task fixture for API serialization tests.
13
+ */
14
+ function baseTask(overrides: Partial<Task> = {}): Task {
15
+ return {
16
+ id: "t1",
17
+ title: "Task",
18
+ content: "Body",
19
+ fields: { status: "backlog", priority: "low" },
20
+ created_at: "2024-01-01T00:00:00.000Z",
21
+ updated_at: "2024-01-02T00:00:00.000Z",
22
+ file_path: "/tmp/t1.md",
23
+ ...overrides,
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Builds a plugin context with default mocked task operations.
29
+ */
30
+ function mockContext(
31
+ overrides: Partial<YarloPluginContext> = {},
32
+ ): YarloPluginContext {
33
+ const config: YarloConfig = {
34
+ ...DEFAULT_CONFIG,
35
+ yarlo_version: "0.1.0",
36
+ };
37
+ const task = baseTask();
38
+ const ctx: YarloPluginContext = {
39
+ config,
40
+ configDir: "/c",
41
+ projectDir: "/p",
42
+ tasks: {
43
+ list: vi.fn(async () => [task]),
44
+ get: vi.fn(async (id: string) => {
45
+ if (id === "t1") return task;
46
+ throw new Error(`Task "${id}" not found`);
47
+ }),
48
+ create: vi.fn(),
49
+ update: vi.fn(async (_id: string, input: UpdateTaskInput) => ({
50
+ ...task,
51
+ title: input.title ?? task.title,
52
+ content: input.content ?? task.content,
53
+ fields: { ...task.fields, ...(input.fields ?? {}) },
54
+ updated_at: "2024-01-03T00:00:00.000Z",
55
+ })),
56
+ delete: vi.fn(),
57
+ },
58
+ ...overrides,
59
+ };
60
+ return ctx;
61
+ }
62
+
63
+ describe("createBoardApp", () => {
64
+ it("GET /api/board returns columns, tasks, and fieldDefinitions", async () => {
65
+ const ctx = mockContext();
66
+ const app = createBoardApp(ctx);
67
+
68
+ const res = await app.request("http://localhost/api/board");
69
+ expect(res.status).toBe(200);
70
+ const json = (await res.json()) as {
71
+ columns: string[];
72
+ tasks: Array<{ id: string; title: string }>;
73
+ fieldDefinitions: unknown[];
74
+ };
75
+ expect(json.columns).toContain("backlog");
76
+ expect(json.tasks).toHaveLength(1);
77
+ expect(json.tasks[0]!.id).toBe("t1");
78
+ expect(json.fieldDefinitions).toEqual(ctx.config.fields);
79
+ expect(ctx.tasks.list).toHaveBeenCalledOnce();
80
+ });
81
+
82
+ it("GET /api/tasks/:id returns 200 when task exists", async () => {
83
+ const ctx = mockContext();
84
+ const app = createBoardApp(ctx);
85
+
86
+ const res = await app.request("http://localhost/api/tasks/t1");
87
+ expect(res.status).toBe(200);
88
+ const json = (await res.json()) as { task: { id: string; title: string } };
89
+ expect(json.task.id).toBe("t1");
90
+ expect(json.task.title).toBe("Task");
91
+ });
92
+
93
+ it("GET /api/tasks/:id returns 404 when task is missing", async () => {
94
+ const ctx = mockContext();
95
+ const app = createBoardApp(ctx);
96
+
97
+ const res = await app.request("http://localhost/api/tasks/missing");
98
+ expect(res.status).toBe(404);
99
+ });
100
+
101
+ it("PATCH /api/tasks/:id returns 400 for invalid JSON", async () => {
102
+ const ctx = mockContext();
103
+ const app = createBoardApp(ctx);
104
+
105
+ const res = await app.request("http://localhost/api/tasks/t1", {
106
+ method: "PATCH",
107
+ headers: { "Content-Type": "application/json" },
108
+ body: "not-json{",
109
+ });
110
+ expect(res.status).toBe(400);
111
+ });
112
+
113
+ it("PATCH /api/tasks/:id returns 400 when body has no updatable fields", async () => {
114
+ const ctx = mockContext();
115
+ const app = createBoardApp(ctx);
116
+
117
+ const res = await app.request("http://localhost/api/tasks/t1", {
118
+ method: "PATCH",
119
+ headers: { "Content-Type": "application/json" },
120
+ body: JSON.stringify({}),
121
+ });
122
+ expect(res.status).toBe(400);
123
+ });
124
+
125
+ it("PATCH /api/tasks/:id returns 400 for invalid status value", async () => {
126
+ const ctx = mockContext();
127
+ const app = createBoardApp(ctx);
128
+
129
+ const res = await app.request("http://localhost/api/tasks/t1", {
130
+ method: "PATCH",
131
+ headers: { "Content-Type": "application/json" },
132
+ body: JSON.stringify({ status: "__not_a_column__" }),
133
+ });
134
+ expect(res.status).toBe(400);
135
+ const json = (await res.json()) as { error: string };
136
+ expect(json.error).toMatch(/Invalid status/i);
137
+ });
138
+
139
+ it("PATCH /api/tasks/:id returns updated task when title is sent", async () => {
140
+ const ctx = mockContext();
141
+ const app = createBoardApp(ctx);
142
+
143
+ const res = await app.request("http://localhost/api/tasks/t1", {
144
+ method: "PATCH",
145
+ headers: { "Content-Type": "application/json" },
146
+ body: JSON.stringify({ title: "Renamed" }),
147
+ });
148
+ expect(res.status).toBe(200);
149
+ const json = (await res.json()) as { task: { title: string } };
150
+ expect(json.task.title).toBe("Renamed");
151
+ expect(ctx.tasks.update).toHaveBeenCalledWith("t1", { title: "Renamed" });
152
+ });
153
+
154
+ it("GET / serves board-ui.html", async () => {
155
+ const ctx = mockContext();
156
+ const app = createBoardApp(ctx);
157
+
158
+ const res = await app.request("http://localhost/");
159
+ expect(res.status).toBe(200);
160
+ const text = await res.text();
161
+ expect(text.length).toBeGreaterThan(100);
162
+ expect(text).toMatch(/html/i);
163
+ });
164
+ });
@@ -0,0 +1,199 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { Hono } from "hono";
5
+ import { z } from "zod";
6
+ import type { FieldDefinition, Task, UpdateTaskInput, YarloPluginContext } from "yarlo-types";
7
+ import { getStatusColumnOptions } from "./status-field.js";
8
+
9
+ const patchBodySchema = z
10
+ .object({
11
+ status: z.string().min(1).optional(),
12
+ title: z.string().optional(),
13
+ content: z.string().optional(),
14
+ fields: z.record(z.unknown()).optional(),
15
+ })
16
+ .refine(
17
+ (d) =>
18
+ d.status !== undefined ||
19
+ d.title !== undefined ||
20
+ d.content !== undefined ||
21
+ d.fields !== undefined,
22
+ { message: "At least one of status, title, content, or fields is required" },
23
+ );
24
+
25
+ export type BoardTaskJson = {
26
+ id: string;
27
+ title: string;
28
+ status: unknown;
29
+ priority: unknown;
30
+ labels: unknown;
31
+ assignee: unknown;
32
+ due_date: unknown;
33
+ estimate: unknown;
34
+ updated_at: string;
35
+ };
36
+
37
+ export type BoardTaskDetailJson = {
38
+ id: string;
39
+ title: string;
40
+ content: string;
41
+ fields: Record<string, unknown>;
42
+ created_at: string;
43
+ updated_at: string;
44
+ };
45
+
46
+ /**
47
+ * Maps a task to a JSON-safe shape for the board UI (no file paths).
48
+ *
49
+ * @param task - Task from yarlo storage
50
+ * @returns Serializable board card payload
51
+ */
52
+ function serializeBoardTask(task: Task): BoardTaskJson {
53
+ const f = task.fields;
54
+ return {
55
+ id: task.id,
56
+ title: task.title,
57
+ status: f.status ?? null,
58
+ priority: f.priority ?? null,
59
+ labels: f.labels ?? null,
60
+ assignee: f.assignee ?? null,
61
+ due_date: f.due_date ?? null,
62
+ estimate: f.estimate ?? null,
63
+ updated_at: task.updated_at,
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Full task payload for detail view and PATCH responses (no file_path).
69
+ *
70
+ * @param task - Task from yarlo storage
71
+ * @returns Serializable task detail
72
+ */
73
+ function serializeBoardTaskDetail(task: Task): BoardTaskDetailJson {
74
+ return {
75
+ id: task.id,
76
+ title: task.title,
77
+ content: task.content,
78
+ fields: { ...task.fields },
79
+ created_at: task.created_at,
80
+ updated_at: task.updated_at,
81
+ };
82
+ }
83
+
84
+ function isNotFoundMessage(msg: string): boolean {
85
+ return (
86
+ msg.includes("not found") ||
87
+ msg.includes("Ambiguous ID") ||
88
+ msg.includes("Ambiguous")
89
+ );
90
+ }
91
+
92
+ /**
93
+ * Builds the Hono app for the local board (HTML UI + JSON API).
94
+ *
95
+ * @param ctx - Plugin context with task operations and config
96
+ * @returns Configured Hono instance
97
+ */
98
+ export function createBoardApp(ctx: YarloPluginContext): Hono {
99
+ const app = new Hono();
100
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
101
+
102
+ app.get("/", async (c) => {
103
+ const htmlPath = path.join(moduleDir, "board-ui.html");
104
+ const html = await fs.readFile(htmlPath, "utf-8");
105
+ return c.html(html);
106
+ });
107
+
108
+ app.get("/api/board", async (c) => {
109
+ const columns = getStatusColumnOptions(ctx.config);
110
+ const tasks = await ctx.tasks.list();
111
+ const fieldDefinitions: FieldDefinition[] = ctx.config.fields;
112
+ return c.json({
113
+ columns,
114
+ tasks: tasks.map(serializeBoardTask),
115
+ fieldDefinitions,
116
+ });
117
+ });
118
+
119
+ app.get("/api/tasks/:id", async (c) => {
120
+ const id = c.req.param("id");
121
+ try {
122
+ const task = await ctx.tasks.get(id);
123
+ return c.json({ task: serializeBoardTaskDetail(task) });
124
+ } catch (e) {
125
+ const msg = e instanceof Error ? e.message : String(e);
126
+ if (isNotFoundMessage(msg)) {
127
+ return c.json({ error: msg }, 404);
128
+ }
129
+ return c.json({ error: msg }, 500);
130
+ }
131
+ });
132
+
133
+ app.patch("/api/tasks/:id", async (c) => {
134
+ const id = c.req.param("id");
135
+ let body: unknown;
136
+ try {
137
+ body = await c.req.json();
138
+ } catch {
139
+ return c.json({ error: "Invalid JSON body" }, 400);
140
+ }
141
+
142
+ const parsed = patchBodySchema.safeParse(body);
143
+ if (!parsed.success) {
144
+ const flat = parsed.error.flatten();
145
+ const refineMsg = parsed.error.errors.find((e) => e.code === "custom");
146
+ return c.json(
147
+ {
148
+ error: refineMsg?.message ?? "Validation failed",
149
+ details: flat,
150
+ },
151
+ 400,
152
+ );
153
+ }
154
+
155
+ const columns = getStatusColumnOptions(ctx.config);
156
+ const fieldPatch: Record<string, unknown> = {};
157
+ if (parsed.data.fields) {
158
+ Object.assign(fieldPatch, parsed.data.fields);
159
+ }
160
+ if (parsed.data.status !== undefined) {
161
+ fieldPatch.status = parsed.data.status;
162
+ }
163
+
164
+ const statusValue =
165
+ typeof fieldPatch.status === "string" ? fieldPatch.status : undefined;
166
+ if (statusValue !== undefined && !columns.includes(statusValue)) {
167
+ return c.json(
168
+ {
169
+ error: `Invalid status: must be one of: ${columns.join(", ")}`,
170
+ },
171
+ 400,
172
+ );
173
+ }
174
+
175
+ const updateInput: UpdateTaskInput = {};
176
+ if (parsed.data.title !== undefined) {
177
+ updateInput.title = parsed.data.title;
178
+ }
179
+ if (parsed.data.content !== undefined) {
180
+ updateInput.content = parsed.data.content;
181
+ }
182
+ if (Object.keys(fieldPatch).length > 0) {
183
+ updateInput.fields = fieldPatch;
184
+ }
185
+
186
+ try {
187
+ const task = await ctx.tasks.update(id, updateInput);
188
+ return c.json({ task: serializeBoardTaskDetail(task) });
189
+ } catch (e) {
190
+ const msg = e instanceof Error ? e.message : String(e);
191
+ if (isNotFoundMessage(msg)) {
192
+ return c.json({ error: msg }, 404);
193
+ }
194
+ return c.json({ error: msg }, 500);
195
+ }
196
+ });
197
+
198
+ return app;
199
+ }