kanban-lite 1.0.4
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/.editorconfig +9 -0
- package/.github/workflows/ci.yml +59 -0
- package/.github/workflows/release.yml +75 -0
- package/.prettierignore +6 -0
- package/.prettierrc.yaml +4 -0
- package/.vscode/extensions.json +3 -0
- package/.vscode/launch.json +17 -0
- package/.vscode/settings.json +21 -0
- package/.vscode/tasks.json +22 -0
- package/.vscodeignore +11 -0
- package/CHANGELOG.md +184 -0
- package/CLAUDE.md +58 -0
- package/CONTRIBUTING.md +114 -0
- package/LICENSE +22 -0
- package/README.md +482 -0
- package/SKILL.md +237 -0
- package/dist/cli.js +8716 -0
- package/dist/extension.js +8463 -0
- package/dist/mcp-server.js +1327 -0
- package/dist/standalone-webview/icons-Dx9MGYqN.js +180 -0
- package/dist/standalone-webview/icons-Dx9MGYqN.js.map +1 -0
- package/dist/standalone-webview/index.js +85 -0
- package/dist/standalone-webview/index.js.map +1 -0
- package/dist/standalone-webview/react-vendor-DkYdDBET.js +25 -0
- package/dist/standalone-webview/react-vendor-DkYdDBET.js.map +1 -0
- package/dist/standalone-webview/style.css +1 -0
- package/dist/standalone.js +7513 -0
- package/dist/webview/icons-Dx9MGYqN.js +180 -0
- package/dist/webview/icons-Dx9MGYqN.js.map +1 -0
- package/dist/webview/index.js +85 -0
- package/dist/webview/index.js.map +1 -0
- package/dist/webview/react-vendor-DkYdDBET.js +25 -0
- package/dist/webview/react-vendor-DkYdDBET.js.map +1 -0
- package/dist/webview/style.css +1 -0
- package/docs/images/board-overview.png +0 -0
- package/docs/images/editor-view.png +0 -0
- package/docs/plans/2026-02-20-kanban-json-config-design.md +74 -0
- package/docs/plans/2026-02-20-kanban-json-config.md +690 -0
- package/eslint.config.mjs +31 -0
- package/package.json +161 -0
- package/postcss.config.js +6 -0
- package/resources/icon-light.png +0 -0
- package/resources/icon-light.svg +105 -0
- package/resources/icon.png +0 -0
- package/resources/icon.svg +105 -0
- package/resources/kanban-dark.svg +21 -0
- package/resources/kanban-light.svg +21 -0
- package/resources/kanban.svg +21 -0
- package/src/cli/index.ts +846 -0
- package/src/extension/FeatureHeaderProvider.ts +370 -0
- package/src/extension/KanbanPanel.ts +973 -0
- package/src/extension/SidebarViewProvider.ts +507 -0
- package/src/extension/featureFileUtils.ts +82 -0
- package/src/extension/index.ts +234 -0
- package/src/mcp-server/index.ts +632 -0
- package/src/sdk/KanbanSDK.ts +349 -0
- package/src/sdk/__tests__/KanbanSDK.test.ts +468 -0
- package/src/sdk/__tests__/parser.test.ts +170 -0
- package/src/sdk/fileUtils.ts +76 -0
- package/src/sdk/index.ts +6 -0
- package/src/sdk/parser.ts +70 -0
- package/src/sdk/types.ts +15 -0
- package/src/shared/config.ts +113 -0
- package/src/shared/editorTypes.ts +14 -0
- package/src/shared/types.ts +120 -0
- package/src/standalone/__tests__/server.integration.test.ts +1916 -0
- package/src/standalone/__tests__/webhooks.test.ts +357 -0
- package/src/standalone/fileUtils.ts +70 -0
- package/src/standalone/index.ts +71 -0
- package/src/standalone/server.ts +1046 -0
- package/src/standalone/webhooks.ts +135 -0
- package/src/webview/App.tsx +469 -0
- package/src/webview/assets/main.css +329 -0
- package/src/webview/assets/standalone-theme.css +130 -0
- package/src/webview/components/ColumnDialog.tsx +119 -0
- package/src/webview/components/CreateFeatureDialog.tsx +524 -0
- package/src/webview/components/DatePicker.tsx +185 -0
- package/src/webview/components/FeatureCard.tsx +186 -0
- package/src/webview/components/FeatureEditor.tsx +623 -0
- package/src/webview/components/KanbanBoard.tsx +144 -0
- package/src/webview/components/KanbanColumn.tsx +159 -0
- package/src/webview/components/MarkdownEditor.tsx +291 -0
- package/src/webview/components/PrioritySelect.tsx +39 -0
- package/src/webview/components/QuickAddInput.tsx +72 -0
- package/src/webview/components/SettingsPanel.tsx +284 -0
- package/src/webview/components/Toolbar.tsx +175 -0
- package/src/webview/components/UndoToast.tsx +70 -0
- package/src/webview/index.html +12 -0
- package/src/webview/lib/utils.ts +6 -0
- package/src/webview/main.tsx +11 -0
- package/src/webview/standalone-main.tsx +13 -0
- package/src/webview/standalone-shim.ts +132 -0
- package/src/webview/standalone.html +12 -0
- package/src/webview/store/index.ts +241 -0
- package/tailwind.config.js +53 -0
- package/tsconfig.json +22 -0
- package/vite.config.ts +36 -0
- package/vite.standalone.config.ts +62 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import * as fs from 'fs/promises'
|
|
2
|
+
import * as path from 'path'
|
|
3
|
+
import { generateKeyBetween } from 'fractional-indexing'
|
|
4
|
+
import type { Feature, FeatureStatus, KanbanColumn } from '../shared/types'
|
|
5
|
+
import { getTitleFromContent, generateFeatureFilename, extractNumericId, DEFAULT_COLUMNS } from '../shared/types'
|
|
6
|
+
import { allocateCardId, syncCardIdCounter } from '../shared/config'
|
|
7
|
+
import { parseFeatureFile, serializeFeature } from './parser'
|
|
8
|
+
import { ensureDirectories, getFeatureFilePath, moveFeatureFile, renameFeatureFile } from './fileUtils'
|
|
9
|
+
import type { CreateCardInput, BoardConfig } from './types'
|
|
10
|
+
|
|
11
|
+
const BOARD_CONFIG_FILE = 'board.json'
|
|
12
|
+
|
|
13
|
+
export class KanbanSDK {
|
|
14
|
+
constructor(public readonly featuresDir: string) {}
|
|
15
|
+
|
|
16
|
+
async init(): Promise<void> {
|
|
17
|
+
await ensureDirectories(this.featuresDir)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// --- Card CRUD ---
|
|
21
|
+
|
|
22
|
+
async listCards(): Promise<Feature[]> {
|
|
23
|
+
await ensureDirectories(this.featuresDir)
|
|
24
|
+
const cards: Feature[] = []
|
|
25
|
+
|
|
26
|
+
// Scan all subdirectories for .md files
|
|
27
|
+
const entries = await fs.readdir(this.featuresDir, { withFileTypes: true })
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
|
|
30
|
+
const subdir = path.join(this.featuresDir, entry.name)
|
|
31
|
+
try {
|
|
32
|
+
const mdFiles = await this._readMdFiles(subdir)
|
|
33
|
+
for (const filePath of mdFiles) {
|
|
34
|
+
const card = await this._loadCard(filePath)
|
|
35
|
+
if (card) cards.push(card)
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
// Skip unreadable directories
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Also load any orphaned root-level .md files (backward compat)
|
|
43
|
+
try {
|
|
44
|
+
const rootFiles = await this._readMdFiles(this.featuresDir)
|
|
45
|
+
for (const filePath of rootFiles) {
|
|
46
|
+
const card = await this._loadCard(filePath)
|
|
47
|
+
if (card) cards.push(card)
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
// Skip
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Sync ID counter with existing cards
|
|
54
|
+
const numericIds = cards
|
|
55
|
+
.map(c => parseInt(c.id, 10))
|
|
56
|
+
.filter(n => !Number.isNaN(n))
|
|
57
|
+
if (numericIds.length > 0) {
|
|
58
|
+
syncCardIdCounter(path.dirname(this.featuresDir), numericIds)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return cards.sort((a, b) => (a.order < b.order ? -1 : a.order > b.order ? 1 : 0))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async getCard(cardId: string): Promise<Feature | null> {
|
|
65
|
+
const cards = await this.listCards()
|
|
66
|
+
return cards.find(c => c.id === cardId) || null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async createCard(data: CreateCardInput): Promise<Feature> {
|
|
70
|
+
await ensureDirectories(this.featuresDir)
|
|
71
|
+
|
|
72
|
+
const status = data.status || 'backlog'
|
|
73
|
+
const priority = data.priority || 'medium'
|
|
74
|
+
const title = getTitleFromContent(data.content)
|
|
75
|
+
const workspaceRoot = path.dirname(this.featuresDir)
|
|
76
|
+
const numericId = allocateCardId(workspaceRoot)
|
|
77
|
+
const filename = generateFeatureFilename(numericId, title)
|
|
78
|
+
const now = new Date().toISOString()
|
|
79
|
+
|
|
80
|
+
// Compute order: place at end of target column
|
|
81
|
+
const cards = await this.listCards()
|
|
82
|
+
const cardsInStatus = cards
|
|
83
|
+
.filter(c => c.status === status)
|
|
84
|
+
.sort((a, b) => (a.order < b.order ? -1 : a.order > b.order ? 1 : 0))
|
|
85
|
+
const lastOrder = cardsInStatus.length > 0
|
|
86
|
+
? cardsInStatus[cardsInStatus.length - 1].order
|
|
87
|
+
: null
|
|
88
|
+
|
|
89
|
+
const card: Feature = {
|
|
90
|
+
id: String(numericId),
|
|
91
|
+
status,
|
|
92
|
+
priority,
|
|
93
|
+
assignee: data.assignee ?? null,
|
|
94
|
+
dueDate: data.dueDate ?? null,
|
|
95
|
+
created: now,
|
|
96
|
+
modified: now,
|
|
97
|
+
completedAt: status === 'done' ? now : null,
|
|
98
|
+
labels: data.labels || [],
|
|
99
|
+
attachments: data.attachments || [],
|
|
100
|
+
order: generateKeyBetween(lastOrder, null),
|
|
101
|
+
content: data.content,
|
|
102
|
+
filePath: getFeatureFilePath(this.featuresDir, status, filename)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
await fs.mkdir(path.dirname(card.filePath), { recursive: true })
|
|
106
|
+
await fs.writeFile(card.filePath, serializeFeature(card), 'utf-8')
|
|
107
|
+
|
|
108
|
+
return card
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async updateCard(cardId: string, updates: Partial<Feature>): Promise<Feature> {
|
|
112
|
+
const card = await this.getCard(cardId)
|
|
113
|
+
if (!card) throw new Error(`Card not found: ${cardId}`)
|
|
114
|
+
|
|
115
|
+
const oldStatus = card.status
|
|
116
|
+
const oldTitle = getTitleFromContent(card.content)
|
|
117
|
+
|
|
118
|
+
// Merge updates (exclude filePath/id from being overwritten)
|
|
119
|
+
const { filePath: _fp, id: _id, ...safeUpdates } = updates
|
|
120
|
+
Object.assign(card, safeUpdates)
|
|
121
|
+
card.modified = new Date().toISOString()
|
|
122
|
+
|
|
123
|
+
if (oldStatus !== card.status) {
|
|
124
|
+
card.completedAt = card.status === 'done' ? new Date().toISOString() : null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Write updated content
|
|
128
|
+
await fs.writeFile(card.filePath, serializeFeature(card), 'utf-8')
|
|
129
|
+
|
|
130
|
+
// Rename file if title changed (numeric-ID cards only)
|
|
131
|
+
const newTitle = getTitleFromContent(card.content)
|
|
132
|
+
const numericId = extractNumericId(card.id)
|
|
133
|
+
if (numericId !== null && newTitle !== oldTitle) {
|
|
134
|
+
const newFilename = generateFeatureFilename(numericId, newTitle)
|
|
135
|
+
card.filePath = await renameFeatureFile(card.filePath, newFilename)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Move file if status changed
|
|
139
|
+
if (oldStatus !== card.status) {
|
|
140
|
+
const newPath = await moveFeatureFile(card.filePath, this.featuresDir, card.status, card.attachments)
|
|
141
|
+
card.filePath = newPath
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return card
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async moveCard(cardId: string, newStatus: FeatureStatus, position?: number): Promise<Feature> {
|
|
148
|
+
const cards = await this.listCards()
|
|
149
|
+
const card = cards.find(c => c.id === cardId)
|
|
150
|
+
if (!card) throw new Error(`Card not found: ${cardId}`)
|
|
151
|
+
|
|
152
|
+
const oldStatus = card.status
|
|
153
|
+
card.status = newStatus
|
|
154
|
+
card.modified = new Date().toISOString()
|
|
155
|
+
|
|
156
|
+
if (oldStatus !== newStatus) {
|
|
157
|
+
card.completedAt = newStatus === 'done' ? new Date().toISOString() : null
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Compute new fractional order
|
|
161
|
+
const targetColumnCards = cards
|
|
162
|
+
.filter(c => c.status === newStatus && c.id !== cardId)
|
|
163
|
+
.sort((a, b) => (a.order < b.order ? -1 : a.order > b.order ? 1 : 0))
|
|
164
|
+
|
|
165
|
+
const pos = position !== undefined
|
|
166
|
+
? Math.max(0, Math.min(position, targetColumnCards.length))
|
|
167
|
+
: targetColumnCards.length
|
|
168
|
+
const before = pos > 0 ? targetColumnCards[pos - 1].order : null
|
|
169
|
+
const after = pos < targetColumnCards.length ? targetColumnCards[pos].order : null
|
|
170
|
+
card.order = generateKeyBetween(before, after)
|
|
171
|
+
|
|
172
|
+
// Write updated content
|
|
173
|
+
await fs.writeFile(card.filePath, serializeFeature(card), 'utf-8')
|
|
174
|
+
|
|
175
|
+
// Move file if status changed
|
|
176
|
+
if (oldStatus !== newStatus) {
|
|
177
|
+
const newPath = await moveFeatureFile(card.filePath, this.featuresDir, newStatus, card.attachments)
|
|
178
|
+
card.filePath = newPath
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return card
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async deleteCard(cardId: string): Promise<void> {
|
|
185
|
+
const card = await this.getCard(cardId)
|
|
186
|
+
if (!card) throw new Error(`Card not found: ${cardId}`)
|
|
187
|
+
await fs.unlink(card.filePath)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async getCardsByStatus(status: FeatureStatus): Promise<Feature[]> {
|
|
191
|
+
const cards = await this.listCards()
|
|
192
|
+
return cards.filter(c => c.status === status)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async getUniqueAssignees(): Promise<string[]> {
|
|
196
|
+
const cards = await this.listCards()
|
|
197
|
+
const assignees = new Set<string>()
|
|
198
|
+
for (const c of cards) {
|
|
199
|
+
if (c.assignee) assignees.add(c.assignee)
|
|
200
|
+
}
|
|
201
|
+
return [...assignees].sort()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async getUniqueLabels(): Promise<string[]> {
|
|
205
|
+
const cards = await this.listCards()
|
|
206
|
+
const labels = new Set<string>()
|
|
207
|
+
for (const c of cards) {
|
|
208
|
+
for (const l of c.labels) labels.add(l)
|
|
209
|
+
}
|
|
210
|
+
return [...labels].sort()
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// --- Attachment management ---
|
|
214
|
+
|
|
215
|
+
async addAttachment(cardId: string, sourcePath: string): Promise<Feature> {
|
|
216
|
+
const card = await this.getCard(cardId)
|
|
217
|
+
if (!card) throw new Error(`Card not found: ${cardId}`)
|
|
218
|
+
|
|
219
|
+
const fileName = path.basename(sourcePath)
|
|
220
|
+
const cardDir = path.dirname(card.filePath)
|
|
221
|
+
const destPath = path.join(cardDir, fileName)
|
|
222
|
+
|
|
223
|
+
// Copy file if it's not already in the card's directory
|
|
224
|
+
const sourceDir = path.dirname(path.resolve(sourcePath))
|
|
225
|
+
if (sourceDir !== cardDir) {
|
|
226
|
+
await fs.copyFile(path.resolve(sourcePath), destPath)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Add to attachments if not already present
|
|
230
|
+
if (!card.attachments.includes(fileName)) {
|
|
231
|
+
card.attachments.push(fileName)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
card.modified = new Date().toISOString()
|
|
235
|
+
await fs.writeFile(card.filePath, serializeFeature(card), 'utf-8')
|
|
236
|
+
|
|
237
|
+
return card
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async removeAttachment(cardId: string, attachment: string): Promise<Feature> {
|
|
241
|
+
const card = await this.getCard(cardId)
|
|
242
|
+
if (!card) throw new Error(`Card not found: ${cardId}`)
|
|
243
|
+
|
|
244
|
+
card.attachments = card.attachments.filter(a => a !== attachment)
|
|
245
|
+
card.modified = new Date().toISOString()
|
|
246
|
+
await fs.writeFile(card.filePath, serializeFeature(card), 'utf-8')
|
|
247
|
+
|
|
248
|
+
return card
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async listAttachments(cardId: string): Promise<string[]> {
|
|
252
|
+
const card = await this.getCard(cardId)
|
|
253
|
+
if (!card) throw new Error(`Card not found: ${cardId}`)
|
|
254
|
+
return card.attachments
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// --- Column management ---
|
|
258
|
+
|
|
259
|
+
async listColumns(): Promise<KanbanColumn[]> {
|
|
260
|
+
const config = await this._readBoardConfig()
|
|
261
|
+
return config.columns
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async addColumn(column: KanbanColumn): Promise<KanbanColumn[]> {
|
|
265
|
+
const config = await this._readBoardConfig()
|
|
266
|
+
if (config.columns.some(c => c.id === column.id)) {
|
|
267
|
+
throw new Error(`Column already exists: ${column.id}`)
|
|
268
|
+
}
|
|
269
|
+
config.columns.push(column)
|
|
270
|
+
await this._writeBoardConfig(config)
|
|
271
|
+
return config.columns
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async updateColumn(columnId: string, updates: Partial<Omit<KanbanColumn, 'id'>>): Promise<KanbanColumn[]> {
|
|
275
|
+
const config = await this._readBoardConfig()
|
|
276
|
+
const col = config.columns.find(c => c.id === columnId)
|
|
277
|
+
if (!col) throw new Error(`Column not found: ${columnId}`)
|
|
278
|
+
if (updates.name !== undefined) col.name = updates.name
|
|
279
|
+
if (updates.color !== undefined) col.color = updates.color
|
|
280
|
+
await this._writeBoardConfig(config)
|
|
281
|
+
return config.columns
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async removeColumn(columnId: string): Promise<KanbanColumn[]> {
|
|
285
|
+
const config = await this._readBoardConfig()
|
|
286
|
+
const idx = config.columns.findIndex(c => c.id === columnId)
|
|
287
|
+
if (idx === -1) throw new Error(`Column not found: ${columnId}`)
|
|
288
|
+
|
|
289
|
+
// Check if any cards use this column
|
|
290
|
+
const cards = await this.listCards()
|
|
291
|
+
const cardsInColumn = cards.filter(c => c.status === columnId)
|
|
292
|
+
if (cardsInColumn.length > 0) {
|
|
293
|
+
throw new Error(`Cannot remove column "${columnId}": ${cardsInColumn.length} card(s) still in this column`)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
config.columns.splice(idx, 1)
|
|
297
|
+
await this._writeBoardConfig(config)
|
|
298
|
+
return config.columns
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async reorderColumns(columnIds: string[]): Promise<KanbanColumn[]> {
|
|
302
|
+
const config = await this._readBoardConfig()
|
|
303
|
+
const colMap = new Map(config.columns.map(c => [c.id, c]))
|
|
304
|
+
|
|
305
|
+
// Validate all IDs exist
|
|
306
|
+
for (const id of columnIds) {
|
|
307
|
+
if (!colMap.has(id)) throw new Error(`Column not found: ${id}`)
|
|
308
|
+
}
|
|
309
|
+
if (columnIds.length !== config.columns.length) {
|
|
310
|
+
throw new Error('Must include all column IDs when reordering')
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
config.columns = columnIds.map(id => colMap.get(id) as KanbanColumn)
|
|
314
|
+
await this._writeBoardConfig(config)
|
|
315
|
+
return config.columns
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// --- Private helpers ---
|
|
319
|
+
|
|
320
|
+
private async _readMdFiles(dir: string): Promise<string[]> {
|
|
321
|
+
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
322
|
+
return entries
|
|
323
|
+
.filter(e => e.isFile() && e.name.endsWith('.md'))
|
|
324
|
+
.map(e => path.join(dir, e.name))
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private async _loadCard(filePath: string): Promise<Feature | null> {
|
|
328
|
+
const content = await fs.readFile(filePath, 'utf-8')
|
|
329
|
+
return parseFeatureFile(content, filePath)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private _boardConfigPath(): string {
|
|
333
|
+
return path.join(this.featuresDir, BOARD_CONFIG_FILE)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private async _readBoardConfig(): Promise<BoardConfig> {
|
|
337
|
+
try {
|
|
338
|
+
const raw = await fs.readFile(this._boardConfigPath(), 'utf-8')
|
|
339
|
+
return JSON.parse(raw) as BoardConfig
|
|
340
|
+
} catch {
|
|
341
|
+
return { columns: [...DEFAULT_COLUMNS] }
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private async _writeBoardConfig(config: BoardConfig): Promise<void> {
|
|
346
|
+
await ensureDirectories(this.featuresDir)
|
|
347
|
+
await fs.writeFile(this._boardConfigPath(), JSON.stringify(config, null, 2), 'utf-8')
|
|
348
|
+
}
|
|
349
|
+
}
|