@omnicoreos/planka-mcp 0.2.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.
Files changed (126) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/CONTRIBUTING.md +37 -0
  3. package/CREDITS.md +39 -0
  4. package/LICENSE +22 -0
  5. package/README.es.md +213 -0
  6. package/README.md +213 -0
  7. package/dist/client.d.ts +96 -0
  8. package/dist/client.d.ts.map +1 -0
  9. package/dist/client.js +281 -0
  10. package/dist/client.js.map +1 -0
  11. package/dist/errors.d.ts +85 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +226 -0
  14. package/dist/errors.js.map +1 -0
  15. package/dist/identity.generated.d.ts +3 -0
  16. package/dist/identity.generated.d.ts.map +1 -0
  17. package/dist/identity.generated.js +4 -0
  18. package/dist/identity.generated.js.map +1 -0
  19. package/dist/index.d.ts +3 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +93 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/operations/attachments.d.ts +34 -0
  24. package/dist/operations/attachments.d.ts.map +1 -0
  25. package/dist/operations/attachments.js +89 -0
  26. package/dist/operations/attachments.js.map +1 -0
  27. package/dist/operations/board-id.d.ts +9 -0
  28. package/dist/operations/board-id.d.ts.map +1 -0
  29. package/dist/operations/board-id.js +86 -0
  30. package/dist/operations/board-id.js.map +1 -0
  31. package/dist/operations/boards.d.ts +143 -0
  32. package/dist/operations/boards.d.ts.map +1 -0
  33. package/dist/operations/boards.js +391 -0
  34. package/dist/operations/boards.js.map +1 -0
  35. package/dist/operations/cards.d.ts +36 -0
  36. package/dist/operations/cards.d.ts.map +1 -0
  37. package/dist/operations/cards.js +81 -0
  38. package/dist/operations/cards.js.map +1 -0
  39. package/dist/operations/comments.d.ts +28 -0
  40. package/dist/operations/comments.d.ts.map +1 -0
  41. package/dist/operations/comments.js +53 -0
  42. package/dist/operations/comments.js.map +1 -0
  43. package/dist/operations/labels.d.ts +60 -0
  44. package/dist/operations/labels.d.ts.map +1 -0
  45. package/dist/operations/labels.js +146 -0
  46. package/dist/operations/labels.js.map +1 -0
  47. package/dist/operations/lists.d.ts +15 -0
  48. package/dist/operations/lists.d.ts.map +1 -0
  49. package/dist/operations/lists.js +40 -0
  50. package/dist/operations/lists.js.map +1 -0
  51. package/dist/operations/projects.d.ts +24 -0
  52. package/dist/operations/projects.d.ts.map +1 -0
  53. package/dist/operations/projects.js +61 -0
  54. package/dist/operations/projects.js.map +1 -0
  55. package/dist/operations/tasks.d.ts +30 -0
  56. package/dist/operations/tasks.d.ts.map +1 -0
  57. package/dist/operations/tasks.js +111 -0
  58. package/dist/operations/tasks.js.map +1 -0
  59. package/dist/schemas/entities.d.ts +389 -0
  60. package/dist/schemas/entities.d.ts.map +1 -0
  61. package/dist/schemas/entities.js +202 -0
  62. package/dist/schemas/entities.js.map +1 -0
  63. package/dist/schemas/requests.d.ts +539 -0
  64. package/dist/schemas/requests.d.ts.map +1 -0
  65. package/dist/schemas/requests.js +187 -0
  66. package/dist/schemas/requests.js.map +1 -0
  67. package/dist/schemas/responses.d.ts +2626 -0
  68. package/dist/schemas/responses.d.ts.map +1 -0
  69. package/dist/schemas/responses.js +75 -0
  70. package/dist/schemas/responses.js.map +1 -0
  71. package/dist/tools/attachments.d.ts +270 -0
  72. package/dist/tools/attachments.d.ts.map +1 -0
  73. package/dist/tools/attachments.js +247 -0
  74. package/dist/tools/attachments.js.map +1 -0
  75. package/dist/tools/cards.d.ts +401 -0
  76. package/dist/tools/cards.d.ts.map +1 -0
  77. package/dist/tools/cards.js +368 -0
  78. package/dist/tools/cards.js.map +1 -0
  79. package/dist/tools/comments.d.ts +217 -0
  80. package/dist/tools/comments.d.ts.map +1 -0
  81. package/dist/tools/comments.js +208 -0
  82. package/dist/tools/comments.js.map +1 -0
  83. package/dist/tools/index.d.ts +1293 -0
  84. package/dist/tools/index.d.ts.map +1 -0
  85. package/dist/tools/index.js +62 -0
  86. package/dist/tools/index.js.map +1 -0
  87. package/dist/tools/labels.d.ts +214 -0
  88. package/dist/tools/labels.d.ts.map +1 -0
  89. package/dist/tools/labels.js +302 -0
  90. package/dist/tools/labels.js.map +1 -0
  91. package/dist/tools/lists.d.ts +117 -0
  92. package/dist/tools/lists.d.ts.map +1 -0
  93. package/dist/tools/lists.js +178 -0
  94. package/dist/tools/lists.js.map +1 -0
  95. package/dist/tools/navigation.d.ts +106 -0
  96. package/dist/tools/navigation.d.ts.map +1 -0
  97. package/dist/tools/navigation.js +151 -0
  98. package/dist/tools/navigation.js.map +1 -0
  99. package/dist/tools/queries.d.ts +380 -0
  100. package/dist/tools/queries.d.ts.map +1 -0
  101. package/dist/tools/queries.js +256 -0
  102. package/dist/tools/queries.js.map +1 -0
  103. package/dist/tools/tasks.d.ts +223 -0
  104. package/dist/tools/tasks.d.ts.map +1 -0
  105. package/dist/tools/tasks.js +169 -0
  106. package/dist/tools/tasks.js.map +1 -0
  107. package/docs/planka-2x-gotchas.md +192 -0
  108. package/docs/tools.md +659 -0
  109. package/docs/troubleshooting.md +138 -0
  110. package/package.json +71 -0
  111. package/project.identity.json +7 -0
  112. package/scripts/bootstrap-board.mjs +160 -0
  113. package/scripts/lib/planka-api.mjs +244 -0
  114. package/scripts/lib/workflow-template.mjs +26 -0
  115. package/scripts/setup.mjs +236 -0
  116. package/scripts/setup.sh +48 -0
  117. package/scripts/sync-identity.mjs +57 -0
  118. package/server.json +43 -0
  119. package/tests/smoke/planka-smoke.mjs +920 -0
  120. package/workflow/README.md +232 -0
  121. package/workflow/board-template.md +115 -0
  122. package/workflow/skills/planka-close-card/SKILL.md +81 -0
  123. package/workflow/skills/planka-orchestrator/SKILL.md +126 -0
  124. package/workflow/worktrees/README.md +104 -0
  125. package/workflow/worktrees/wt.conf.example +24 -0
  126. package/workflow/worktrees/wt.sh +345 -0
@@ -0,0 +1,138 @@
1
+ # Troubleshooting
2
+
3
+ Match the symptom first. Each entry gives the observed behavior, the real cause, and
4
+ the fix.
5
+
6
+ ## “The agent says the card has no comments, but I can see comments”
7
+
8
+ **Cause:** the old MCP reads `included.comments` from the card response. Planka 2.x
9
+ does not populate it, so HTTP succeeds with zero items.
10
+
11
+ **Fix:** make sure Claude Code runs this fork, restart Claude Code, and call
12
+ `planka_get_comments` again. Run the opt-in smoke test if the result is still
13
+ suspicious; it compares MCP output to `GET /api/cards/:id/comments`.
14
+
15
+ ## “It says the label was added, but the card has no label”
16
+
17
+ **Cause:** the old tool schema knows `addLabelIds` but silently drops `labelIds`.
18
+
19
+ **Fix:** use this fork. It accepts both names and re-reads the card. Treat any result
20
+ with a non-empty `failed` array as an error.
21
+
22
+ ## “Removing a label returns 404, or the label remains”
23
+
24
+ **Cause:** Planka 2.x deletes by `labelId:<labelId>` criteria, not by the ID of the
25
+ card-label junction record.
26
+
27
+ **Fix:** upgrade to this fork and pass the board label ID in `removeLabelIds`:
28
+
29
+ ```json
30
+ {
31
+ "cardId": "<CARD_ID>",
32
+ "removeLabelIds": ["<LABEL_ID>"]
33
+ }
34
+ ```
35
+
36
+ ## “`planka_get_board` fails because one color is invalid”
37
+
38
+ **Cause:** the old response schema has a closed color enum. One newer Planka color
39
+ causes Zod to reject the entire board.
40
+
41
+ **Fix:** use this fork. Read paths accept backend color strings. Write paths still
42
+ validate known colors.
43
+
44
+ ## “My credential is expired,” but the password works in the browser
45
+
46
+ **Cause:** `http://` redirects to `https://`. The authentication POST body can be
47
+ lost in the redirect, and the final response is HTML instead of the expected JSON
48
+ token.
49
+
50
+ **Fix:** set `PLANKA_BASE_URL` to the final HTTPS origin:
51
+
52
+ ```bash
53
+ export PLANKA_BASE_URL="https://planka.example.com"
54
+ ```
55
+
56
+ Run `./scripts/setup.sh` again. It validates `POST /api/access-tokens` before writing
57
+ anything and calls out redirect/HTML responses directly.
58
+
59
+ ## “Board not found” while creating a card, list, or label
60
+
61
+ **Cause:** Planka can answer 404 when the agent user lacks write permission. Instance
62
+ administrator access alone is not the same as board write access.
63
+
64
+ **Fix:** give the agent user both:
65
+
66
+ 1. project manager access when it must create boards or manage project-level state;
67
+ 2. board membership with role `editor` for card, list, label, task, and comment
68
+ writes.
69
+
70
+ The board-membership route in Planka 2.x is:
71
+
72
+ ```text
73
+ POST /api/boards/<BOARD_ID>/board-memberships
74
+ ```
75
+
76
+ with a body containing `userId` and `role: "editor"`.
77
+
78
+ ## “Creating a list returns `E_MISSING_OR_INVALID_PARAMS`”
79
+
80
+ **Cause:** Planka 2.x requires `type` as well as `name` and `position`. Older MCP
81
+ builds omit it.
82
+
83
+ **Fix:** use this fork. `planka_manage_lists` sends `type: "active"` by default and
84
+ also accepts `closed`.
85
+
86
+ ## “The MCP tools do not appear in Claude Code”
87
+
88
+ **Cause:** MCP configuration is loaded at session startup. A project-scoped
89
+ `.mcp.json` may also be waiting for workspace trust and explicit approval.
90
+
91
+ **Fix:** close and restart Claude Code, approve the project when prompted, then run:
92
+
93
+ ```bash
94
+ claude mcp list
95
+ claude mcp get planka
96
+ ```
97
+
98
+ Inside Claude Code, `/mcp` shows connection details. A project server waiting for
99
+ approval appears as pending rather than connected.
100
+
101
+ ## “`claude mcp add` says the server already exists”
102
+
103
+ **Cause:** a previous local or user-scoped entry has the same name.
104
+
105
+ **Fix:** `setup.sh` removes and recreates the user-scoped `planka` entry
106
+ idempotently. To do it manually:
107
+
108
+ ```bash
109
+ claude mcp remove planka --scope user
110
+ claude mcp add --scope user --transport stdio planka -- \
111
+ "$HOME/.local/bin/planka-mcp"
112
+ ```
113
+
114
+ ## “The smoke test says it was skipped”
115
+
116
+ **Cause:** the real smoke test is opt-in and one of its four environment variables is
117
+ missing.
118
+
119
+ **Fix:** set all required values and choose a writable board:
120
+
121
+ ```bash
122
+ export PLANKA_BASE_URL="https://planka.example.com"
123
+ export PLANKA_AGENT_EMAIL="agent@example.com"
124
+ export PLANKA_AGENT_PASSWORD="<YOUR_PASSWORD>"
125
+ export PLANKA_SMOKE_BOARD_ID="<BOARD_ID>"
126
+ npm run test:smoke
127
+ ```
128
+
129
+ The test creates and deletes a temporary card. Do not point it at a board where the
130
+ agent user must be read-only.
131
+
132
+ ## “Authentication returns HTTP 502 or another HTML error page”
133
+
134
+ **Cause:** the reverse proxy or Planka service is unavailable. This is different from
135
+ an invalid JSON token response even though both can look like HTML to a client.
136
+
137
+ **Fix:** restore the Planka service, verify the final HTTPS URL in a browser, and
138
+ rerun setup. The setup script does not write configuration after failed validation.
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@omnicoreos/planka-mcp",
3
+ "version": "0.2.0",
4
+ "description": "A Planka 2.x MCP server with verified writes and an optional agent workflow",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "bin": {
9
+ "planka-mcp": "dist/index.js"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "docs",
17
+ "scripts",
18
+ "tests/smoke",
19
+ "workflow",
20
+ "CHANGELOG.md",
21
+ "CONTRIBUTING.md",
22
+ "LICENSE",
23
+ "CREDITS.md",
24
+ "README.md",
25
+ "README.es.md",
26
+ "project.identity.json",
27
+ "server.json"
28
+ ],
29
+ "scripts": {
30
+ "sync:identity": "node scripts/sync-identity.mjs",
31
+ "check:identity": "node scripts/sync-identity.mjs --check",
32
+ "build": "npm run check:identity && tsc",
33
+ "test": "vitest run",
34
+ "test:watch": "vitest",
35
+ "test:smoke": "npm run build && node tests/smoke/planka-smoke.mjs",
36
+ "lint": "tsc --noEmit",
37
+ "prepublishOnly": "npm run build && npm test"
38
+ },
39
+ "keywords": [
40
+ "mcp",
41
+ "mcp-server",
42
+ "model-context-protocol",
43
+ "planka",
44
+ "kanban",
45
+ "claude-code",
46
+ "ai-agent"
47
+ ],
48
+ "author": "planka-mcp contributors",
49
+ "license": "MIT",
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "https://github.com/omnicoreos/planka-mcp.git"
53
+ },
54
+ "bugs": {
55
+ "url": "https://github.com/omnicoreos/planka-mcp/issues"
56
+ },
57
+ "homepage": "https://github.com/omnicoreos/planka-mcp#readme",
58
+ "engines": {
59
+ "node": ">=18.0.0"
60
+ },
61
+ "dependencies": {
62
+ "@modelcontextprotocol/sdk": "^1.30.0",
63
+ "zod": "^3.25.76"
64
+ },
65
+ "devDependencies": {
66
+ "@types/node": "^18.19.130",
67
+ "typescript": "^5.9.3",
68
+ "vitest": "^3.2.7"
69
+ },
70
+ "mcpName": "io.github.omnicoreos/planka-mcp"
71
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "repositoryName": "planka-mcp",
3
+ "packageName": "@omnicoreos/planka-mcp",
4
+ "binName": "planka-mcp",
5
+ "mcpServerName": "io.github.omnicoreos/planka-mcp",
6
+ "repositoryUrl": "https://github.com/omnicoreos/planka-mcp"
7
+ }
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { pathToFileURL } from "node:url";
4
+ import {
5
+ PlankaApi,
6
+ PlankaApiError,
7
+ boardEntities,
8
+ } from "./lib/planka-api.mjs";
9
+ import {
10
+ WORKFLOW_LABELS,
11
+ WORKFLOW_LISTS,
12
+ nextPosition,
13
+ } from "./lib/workflow-template.mjs";
14
+
15
+ function parseArgs(argv) {
16
+ const args = { force: false, json: false };
17
+ for (let index = 0; index < argv.length; index += 1) {
18
+ const arg = argv[index];
19
+ if (arg === "--force") args.force = true;
20
+ else if (arg === "--json") args.json = true;
21
+ else if (arg === "--board-id") args.boardId = argv[++index];
22
+ else if (arg === "--project-id") args.projectId = argv[++index];
23
+ else if (arg === "--board-name") args.boardName = argv[++index];
24
+ else if (arg === "--help" || arg === "-h") args.help = true;
25
+ else throw new Error(`Unknown argument: ${arg}`);
26
+ }
27
+ return args;
28
+ }
29
+
30
+ function usage() {
31
+ return `Usage:
32
+ node scripts/bootstrap-board.mjs --board-id <id>
33
+ node scripts/bootstrap-board.mjs --project-id <id> --board-name "Agent Work"
34
+
35
+ Environment:
36
+ PLANKA_BASE_URL, PLANKA_AGENT_EMAIL, PLANKA_AGENT_PASSWORD
37
+
38
+ Options:
39
+ --force Add the template even when the board already has unrelated content
40
+ --json Print the result as JSON`;
41
+ }
42
+
43
+ export async function bootstrapBoard(
44
+ api,
45
+ { boardId, projectId, boardName = "Agent Work", force = false } = {}
46
+ ) {
47
+ let createdBoard = false;
48
+ if (!boardId) {
49
+ if (!projectId) {
50
+ throw new Error("Pass either boardId or projectId");
51
+ }
52
+ const created = await api.createBoard(projectId, boardName);
53
+ boardId = created?.item?.id;
54
+ if (!boardId) throw new Error("Planka did not return the new board id");
55
+ createdBoard = true;
56
+ }
57
+
58
+ let state = boardEntities(await api.getBoard(boardId));
59
+ const templateListNames = new Set(WORKFLOW_LISTS.map((item) => item.name));
60
+ const matchingLists = state.lists.filter((item) =>
61
+ templateListNames.has(item.name)
62
+ );
63
+ const unrelatedLists = state.lists.filter(
64
+ (item) => item.name && !templateListNames.has(item.name)
65
+ );
66
+
67
+ if (
68
+ !force &&
69
+ matchingLists.length === 0 &&
70
+ (unrelatedLists.length > 0 || state.cards.length > 0)
71
+ ) {
72
+ throw new Error(
73
+ "This board is not empty and does not look bootstrapped. Use --force only if you intentionally want to add the workflow alongside existing content."
74
+ );
75
+ }
76
+
77
+ const createdLists = [];
78
+ const existingListNames = new Set(state.lists.map((item) => item.name));
79
+ for (const [index, definition] of WORKFLOW_LISTS.entries()) {
80
+ if (existingListNames.has(definition.name)) continue;
81
+ const response = await api.createList(
82
+ boardId,
83
+ definition.name,
84
+ nextPosition(index),
85
+ definition.type
86
+ );
87
+ createdLists.push(response.item);
88
+ }
89
+
90
+ state = boardEntities(await api.getBoard(boardId));
91
+ const createdLabels = [];
92
+ const existingLabelNames = new Set(state.labels.map((item) => item.name));
93
+ for (const [index, definition] of WORKFLOW_LABELS.entries()) {
94
+ if (existingLabelNames.has(definition.name)) continue;
95
+ const response = await api.createLabel(
96
+ boardId,
97
+ definition.name,
98
+ definition.color,
99
+ nextPosition(index)
100
+ );
101
+ createdLabels.push(response.item);
102
+ }
103
+
104
+ const verified = boardEntities(await api.getBoard(boardId));
105
+ const missingLists = WORKFLOW_LISTS.filter(
106
+ (definition) =>
107
+ !verified.lists.some(
108
+ (item) =>
109
+ item.name === definition.name &&
110
+ (!item.type || item.type === definition.type)
111
+ )
112
+ );
113
+ const missingLabels = WORKFLOW_LABELS.filter(
114
+ (definition) =>
115
+ !verified.labels.some((item) => item.name === definition.name)
116
+ );
117
+ if (missingLists.length || missingLabels.length) {
118
+ throw new Error(
119
+ `Bootstrap verification failed. Missing lists: ${missingLists.map((item) => item.name).join(", ") || "none"}; missing labels: ${missingLabels.map((item) => item.name).join(", ") || "none"}`
120
+ );
121
+ }
122
+
123
+ return {
124
+ boardId,
125
+ boardName: verified.board?.name || boardName,
126
+ createdBoard,
127
+ createdLists: createdLists.map((item) => item.id),
128
+ createdLabels: createdLabels.map((item) => item.id),
129
+ listCount: WORKFLOW_LISTS.length,
130
+ labelCount: WORKFLOW_LABELS.length,
131
+ };
132
+ }
133
+
134
+ async function main() {
135
+ const args = parseArgs(process.argv.slice(2));
136
+ if (args.help) {
137
+ console.log(usage());
138
+ return;
139
+ }
140
+
141
+ const api = await PlankaApi.fromCredentials({
142
+ baseUrl: process.env.PLANKA_BASE_URL,
143
+ email: process.env.PLANKA_AGENT_EMAIL,
144
+ password: process.env.PLANKA_AGENT_PASSWORD,
145
+ });
146
+ const result = await bootstrapBoard(api, args);
147
+ if (args.json) console.log(JSON.stringify(result, null, 2));
148
+ else {
149
+ console.log(`Workflow ready on board: ${result.boardName} (${result.boardId})`);
150
+ console.log(`Created ${result.createdLists.length} list(s) and ${result.createdLabels.length} label(s).`);
151
+ }
152
+ }
153
+
154
+ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
155
+ main().catch((error) => {
156
+ const prefix = error instanceof PlankaApiError ? "Planka API error" : "Error";
157
+ console.error(`${prefix}: ${error.message}`);
158
+ process.exit(1);
159
+ });
160
+ }
@@ -0,0 +1,244 @@
1
+ export class PlankaApiError extends Error {
2
+ constructor(message, { status = 0, body = null } = {}) {
3
+ super(message);
4
+ this.name = "PlankaApiError";
5
+ this.status = status;
6
+ this.body = body;
7
+ }
8
+ }
9
+
10
+ export function normalizeBaseUrl(value) {
11
+ let url;
12
+ try {
13
+ url = new URL(value);
14
+ } catch {
15
+ throw new PlankaApiError(`Invalid Planka URL: ${value}`);
16
+ }
17
+ if (!['http:', 'https:'].includes(url.protocol)) {
18
+ throw new PlankaApiError("PLANKA_BASE_URL must use http:// or https://");
19
+ }
20
+ return url.toString().replace(/\/+$/, "");
21
+ }
22
+
23
+ async function parseResponse(response, context) {
24
+ const text = await response.text();
25
+ let body = null;
26
+ if (text) {
27
+ try {
28
+ body = JSON.parse(text);
29
+ } catch {
30
+ const looksLikeHtml = /<\s*(?:!doctype|html|head|body)\b/i.test(text);
31
+ const hint = looksLikeHtml
32
+ ? " The server returned HTML instead of JSON. If the URL starts with http://, configure the final https:// URL directly; redirects can drop an authentication POST body."
33
+ : " The response was not valid JSON.";
34
+ throw new PlankaApiError(`${context} failed.${hint}`, {
35
+ status: response.status,
36
+ });
37
+ }
38
+ }
39
+
40
+ if (!response.ok) {
41
+ const detail =
42
+ body && typeof body === "object"
43
+ ? body.message || body.code || JSON.stringify(body)
44
+ : response.statusText;
45
+ throw new PlankaApiError(
46
+ `${context} failed: HTTP ${response.status}${detail ? ` — ${detail}` : ""}`,
47
+ { status: response.status, body }
48
+ );
49
+ }
50
+ return body;
51
+ }
52
+
53
+ export async function login({ baseUrl, email, password, fetchImpl = fetch }) {
54
+ const normalized = normalizeBaseUrl(baseUrl);
55
+ let response;
56
+ try {
57
+ response = await fetchImpl(`${normalized}/api/access-tokens`, {
58
+ method: "POST",
59
+ headers: { "Content-Type": "application/json" },
60
+ body: JSON.stringify({ emailOrUsername: email, password }),
61
+ redirect: "manual",
62
+ signal: AbortSignal.timeout(30000),
63
+ });
64
+ } catch (error) {
65
+ if (error instanceof PlankaApiError) throw error;
66
+ throw new PlankaApiError(
67
+ `Could not connect to Planka at ${normalized}: ${error instanceof Error ? error.message : String(error)}`
68
+ );
69
+ }
70
+
71
+ if (response.status >= 300 && response.status < 400) {
72
+ const destination = response.headers.get("location");
73
+ throw new PlankaApiError(
74
+ `Authentication was redirected${destination ? ` to ${destination}` : ""}. Use the final https:// Planka URL directly; a 301/302 can drop the POST body.` ,
75
+ { status: response.status }
76
+ );
77
+ }
78
+
79
+ const body = await parseResponse(response, "Authentication");
80
+ if (!body || typeof body.item !== "string") {
81
+ throw new PlankaApiError(
82
+ "Authentication returned JSON, but not a Planka access token. Check the base URL and credentials."
83
+ );
84
+ }
85
+ return { baseUrl: normalized, token: body.item };
86
+ }
87
+
88
+ export class PlankaApi {
89
+ constructor({ baseUrl, token, fetchImpl = fetch }) {
90
+ this.baseUrl = normalizeBaseUrl(baseUrl);
91
+ this.token = token;
92
+ this.fetchImpl = fetchImpl;
93
+ }
94
+
95
+ static async fromCredentials(credentials) {
96
+ const session = await login(credentials);
97
+ return new PlankaApi({
98
+ ...session,
99
+ fetchImpl: credentials.fetchImpl,
100
+ });
101
+ }
102
+
103
+ async request(method, path, body) {
104
+ const headers = { Authorization: `Bearer ${this.token}` };
105
+ if (body !== undefined) headers["Content-Type"] = "application/json";
106
+
107
+ let response;
108
+ try {
109
+ response = await this.fetchImpl(`${this.baseUrl}${path}`, {
110
+ method,
111
+ headers,
112
+ body: body === undefined ? undefined : JSON.stringify(body),
113
+ redirect: "manual",
114
+ signal: AbortSignal.timeout(30000),
115
+ });
116
+ } catch (error) {
117
+ throw new PlankaApiError(
118
+ `${method} ${path} failed: ${error instanceof Error ? error.message : String(error)}`
119
+ );
120
+ }
121
+
122
+ if (response.status >= 300 && response.status < 400) {
123
+ throw new PlankaApiError(
124
+ `${method} ${path} was redirected. Configure the final https:// URL directly.`,
125
+ { status: response.status }
126
+ );
127
+ }
128
+ if (response.status === 204) return null;
129
+ return parseResponse(response, `${method} ${path}`);
130
+ }
131
+
132
+ get(path) {
133
+ return this.request("GET", path);
134
+ }
135
+
136
+ post(path, body) {
137
+ return this.request("POST", path, body);
138
+ }
139
+
140
+ patch(path, body) {
141
+ return this.request("PATCH", path, body);
142
+ }
143
+
144
+ delete(path) {
145
+ return this.request("DELETE", path);
146
+ }
147
+
148
+ getProjects() {
149
+ return this.get("/api/projects");
150
+ }
151
+
152
+ getBoard(boardId) {
153
+ return this.get(`/api/boards/${boardId}`);
154
+ }
155
+
156
+ createBoard(projectId, name, position = 65536) {
157
+ return this.post(`/api/projects/${projectId}/boards`, { name, position });
158
+ }
159
+
160
+ createList(boardId, name, position, type = "active") {
161
+ return this.post(`/api/boards/${boardId}/lists`, {
162
+ name,
163
+ position,
164
+ type,
165
+ });
166
+ }
167
+
168
+ createLabel(boardId, name, color, position) {
169
+ return this.post(`/api/boards/${boardId}/labels`, {
170
+ name,
171
+ color,
172
+ position,
173
+ });
174
+ }
175
+
176
+ deleteLabel(labelId) {
177
+ return this.delete(`/api/labels/${labelId}`);
178
+ }
179
+
180
+ createCard(listId, name, description = "") {
181
+ return this.post(`/api/lists/${listId}/cards`, {
182
+ name,
183
+ description,
184
+ position: 65536,
185
+ type: "project",
186
+ });
187
+ }
188
+
189
+ deleteCard(cardId) {
190
+ return this.delete(`/api/cards/${cardId}`);
191
+ }
192
+
193
+ getComments(cardId) {
194
+ return this.get(`/api/cards/${cardId}/comments`);
195
+ }
196
+
197
+ getCard(cardId) {
198
+ return this.get(`/api/cards/${cardId}`);
199
+ }
200
+
201
+ deleteList(listId) {
202
+ return this.delete(`/api/lists/${listId}`);
203
+ }
204
+
205
+ deleteAttachment(attachmentId) {
206
+ return this.delete(`/api/attachments/${attachmentId}`);
207
+ }
208
+
209
+ /**
210
+ * Authenticated binary download. Planka 2.0 serves attachment contents with
211
+ * the token as a Bearer header or as an `accessToken` cookie depending on
212
+ * the deployment, so send both. Accepts an absolute URL or a relative path.
213
+ */
214
+ async getBinary(urlOrPath) {
215
+ const url = urlOrPath.startsWith("http")
216
+ ? urlOrPath
217
+ : `${this.baseUrl}${urlOrPath}`;
218
+ const response = await this.fetchImpl(url, {
219
+ headers: {
220
+ Authorization: `Bearer ${this.token}`,
221
+ Cookie: `accessToken=${this.token}`,
222
+ },
223
+ redirect: "manual",
224
+ signal: AbortSignal.timeout(30000),
225
+ });
226
+ if (!response.ok) {
227
+ throw new PlankaApiError(`GET ${url} failed: HTTP ${response.status}`, {
228
+ status: response.status,
229
+ });
230
+ }
231
+ return new Uint8Array(await response.arrayBuffer());
232
+ }
233
+ }
234
+
235
+ export function boardEntities(payload) {
236
+ const included = payload?.included || {};
237
+ return {
238
+ board: payload?.item,
239
+ lists: Array.isArray(included.lists) ? included.lists : [],
240
+ cards: Array.isArray(included.cards) ? included.cards : [],
241
+ labels: Array.isArray(included.labels) ? included.labels : [],
242
+ cardLabels: Array.isArray(included.cardLabels) ? included.cardLabels : [],
243
+ };
244
+ }
@@ -0,0 +1,26 @@
1
+ export const WORKFLOW_LISTS = [
2
+ { name: "Pending", type: "active" },
3
+ { name: "Needs your decision", type: "active" },
4
+ { name: "In worktree", type: "active" },
5
+ { name: "Test it", type: "active" },
6
+ { name: "Review in main", type: "active" },
7
+ { name: "Merged", type: "closed" },
8
+ ];
9
+
10
+ export const WORKFLOW_LABELS = [
11
+ { name: "needs-decision", color: "apricot-red" },
12
+ { name: "decided", color: "modern-green" },
13
+ { name: "tested-ok", color: "french-coast" },
14
+ { name: "fast-track", color: "sunny-grass" },
15
+ { name: "standard", color: "morning-sky" },
16
+ { name: "serial", color: "red-burgundy" },
17
+ { name: "zone:frontend", color: "pink-tulip" },
18
+ { name: "zone:backend", color: "lagoon-blue" },
19
+ { name: "zone:data", color: "egg-yellow" },
20
+ { name: "zone:infra", color: "dark-granite" },
21
+ { name: "zone:shared-ui", color: "sweet-lilac" },
22
+ ];
23
+
24
+ export function nextPosition(index) {
25
+ return (index + 1) * 65536;
26
+ }