kanban-lite 1.2.2 → 1.2.3
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/dist/cli.js +168 -85
- package/dist/extension.js +157 -74
- package/dist/mcp-server.js +94 -33
- package/dist/sdk/index.cjs +94 -32
- package/dist/sdk/index.mjs +94 -32
- package/dist/sdk/sdk/KanbanSDK.d.ts +5 -9
- package/dist/sdk/sdk/modules/cards.d.ts +11 -2
- package/dist/sdk/sdk/types.d.ts +1 -1
- package/dist/sdk/shared/config.d.ts +17 -1
- package/dist/standalone-webview/index.js +15 -15
- package/dist/standalone-webview/index.js.map +1 -1
- package/dist/standalone.js +219 -121
- package/package.json +1 -1
- package/src/mcp-server/index.ts +1 -1
- package/src/sdk/KanbanSDK.d.ts +5 -9
- package/src/sdk/KanbanSDK.ts +7 -10
- package/src/sdk/integrationCatalog.ts +1 -0
- package/src/sdk/modules/cards.ts +13 -24
- package/src/sdk/plugins/index.ts +10 -2
- package/src/sdk/types.d.ts +1 -1
- package/src/sdk/types.ts +1 -0
- package/src/sdk/webhooks.ts +19 -2
- package/src/shared/config.ts +124 -2
- package/src/standalone/internal/runtime.ts +11 -6
- package/src/standalone/server.ts +21 -7
- package/src/standalone/watcherSetup.ts +9 -0
- package/src/webview/standalone-shim.ts +2 -1
package/package.json
CHANGED
package/src/mcp-server/index.ts
CHANGED
|
@@ -789,7 +789,7 @@ async function main(): Promise<void> {
|
|
|
789
789
|
|
|
790
790
|
server.tool(
|
|
791
791
|
'trigger_action',
|
|
792
|
-
'Trigger a named action on a card. The action name must match one of the card\'s configured actions.
|
|
792
|
+
'Trigger a named action on a card. The action name must match one of the card\'s configured actions. Emits a card.action.triggered event delivered to registered webhooks.',
|
|
793
793
|
{
|
|
794
794
|
card_id: z.string().describe('Card ID (partial match supported)'),
|
|
795
795
|
action: z.string().describe('Action name to trigger'),
|
package/src/sdk/KanbanSDK.d.ts
CHANGED
|
@@ -958,21 +958,17 @@ export declare class KanbanSDK {
|
|
|
958
958
|
*/
|
|
959
959
|
submitForm(input: SubmitFormInput): Promise<SubmitFormResult>;
|
|
960
960
|
/**
|
|
961
|
-
* Triggers a named action for a card
|
|
962
|
-
* configured in `.kanban.json`.
|
|
961
|
+
* Triggers a named action for a card.
|
|
963
962
|
*
|
|
964
|
-
*
|
|
965
|
-
*
|
|
966
|
-
*
|
|
967
|
-
* ```
|
|
963
|
+
* Validates the card, appends an activity log entry, and emits the
|
|
964
|
+
* `card.action.triggered` after-event so registered webhooks receive
|
|
965
|
+
* the action payload automatically.
|
|
968
966
|
*
|
|
969
967
|
* @param cardId - The ID of the card to trigger the action for.
|
|
970
968
|
* @param action - The action name string (e.g. `'retry'`, `'sendEmail'`).
|
|
971
969
|
* @param boardId - Optional board ID. Defaults to the workspace's default board.
|
|
972
|
-
* @returns A promise
|
|
973
|
-
* @throws {Error} If no `actionWebhookUrl` is configured in `.kanban.json`.
|
|
970
|
+
* @returns A promise that resolves when the action has been processed.
|
|
974
971
|
* @throws {Error} If the card is not found.
|
|
975
|
-
* @throws {Error} If the webhook responds with a non-2xx status.
|
|
976
972
|
*
|
|
977
973
|
* @example
|
|
978
974
|
* ```ts
|
package/src/sdk/KanbanSDK.ts
CHANGED
|
@@ -1737,21 +1737,17 @@ export class KanbanSDK {
|
|
|
1737
1737
|
}
|
|
1738
1738
|
|
|
1739
1739
|
/**
|
|
1740
|
-
* Triggers a named action for a card
|
|
1741
|
-
* configured in `.kanban.json`.
|
|
1740
|
+
* Triggers a named action for a card.
|
|
1742
1741
|
*
|
|
1743
|
-
*
|
|
1744
|
-
*
|
|
1745
|
-
*
|
|
1746
|
-
* ```
|
|
1742
|
+
* Validates the card, appends an activity log entry, and emits the
|
|
1743
|
+
* `card.action.triggered` after-event so registered webhooks receive
|
|
1744
|
+
* the action payload automatically.
|
|
1747
1745
|
*
|
|
1748
1746
|
* @param cardId - The ID of the card to trigger the action for.
|
|
1749
1747
|
* @param action - The action name string (e.g. `'retry'`, `'sendEmail'`).
|
|
1750
1748
|
* @param boardId - Optional board ID. Defaults to the workspace's default board.
|
|
1751
|
-
* @returns A promise
|
|
1752
|
-
* @throws {Error} If no `actionWebhookUrl` is configured in `.kanban.json`.
|
|
1749
|
+
* @returns A promise that resolves when the action has been processed.
|
|
1753
1750
|
* @throws {Error} If the card is not found.
|
|
1754
|
-
* @throws {Error} If the webhook responds with a non-2xx status.
|
|
1755
1751
|
*
|
|
1756
1752
|
* @example
|
|
1757
1753
|
* ```ts
|
|
@@ -1761,7 +1757,8 @@ export class KanbanSDK {
|
|
|
1761
1757
|
*/
|
|
1762
1758
|
async triggerAction(cardId: string, action: string, boardId?: string): Promise<void> {
|
|
1763
1759
|
const mergedInput = await this._runBeforeEvent<MethodInput<typeof Cards.triggerAction>>('card.action.trigger', { cardId, action, boardId }, undefined, boardId)
|
|
1764
|
-
|
|
1760
|
+
const payload = await Cards.triggerAction(this, mergedInput)
|
|
1761
|
+
this._runAfterEvent('card.action.triggered', payload, undefined, payload.board)
|
|
1765
1762
|
}
|
|
1766
1763
|
|
|
1767
1764
|
/**
|
|
@@ -171,6 +171,7 @@ const AFTER_ENTRIES = [
|
|
|
171
171
|
{ event: 'board.updated' as SDKAfterEventType, resource: 'board' as KanbanResource, label: 'Board updated' },
|
|
172
172
|
{ event: 'board.deleted' as SDKAfterEventType, resource: 'board' as KanbanResource, label: 'Board deleted' },
|
|
173
173
|
{ event: 'board.action' as SDKAfterEventType, resource: 'board' as KanbanResource, label: 'Board action triggered' },
|
|
174
|
+
{ event: 'card.action.triggered' as SDKAfterEventType, resource: 'card' as KanbanResource, label: 'Card action triggered' },
|
|
174
175
|
{ event: 'board.log.added' as SDKAfterEventType, resource: 'board' as KanbanResource, label: 'Board log added' },
|
|
175
176
|
{ event: 'board.log.cleared' as SDKAfterEventType, resource: 'board' as KanbanResource, label: 'Board log cleared' },
|
|
176
177
|
// card logs
|
package/src/sdk/modules/cards.ts
CHANGED
|
@@ -452,39 +452,21 @@ export async function updateCard(
|
|
|
452
452
|
}
|
|
453
453
|
|
|
454
454
|
/**
|
|
455
|
-
* Triggers a named action for a card
|
|
455
|
+
* Triggers a named action for a card.
|
|
456
|
+
*
|
|
457
|
+
* Validates the card exists, appends an activity log entry, and returns the
|
|
458
|
+
* action payload. Webhook delivery is handled by the webhook plugin via the
|
|
459
|
+
* `card.action.triggered` after-event emitted by {@link KanbanSDK.triggerAction}.
|
|
456
460
|
*/
|
|
457
461
|
export async function triggerAction(
|
|
458
462
|
ctx: SDKContext,
|
|
459
463
|
{ cardId, action, boardId }: { cardId: string; action: string; boardId?: string }
|
|
460
|
-
): Promise<
|
|
461
|
-
const config = readConfig(ctx.workspaceRoot)
|
|
462
|
-
const { actionWebhookUrl } = config
|
|
463
|
-
if (!actionWebhookUrl) {
|
|
464
|
-
throw new Error('No action webhook URL configured. Set actionWebhookUrl in .kanban.json')
|
|
465
|
-
}
|
|
466
|
-
|
|
464
|
+
): Promise<{ action: string; board: string; list: string; card: Omit<Card, 'filePath'> }> {
|
|
467
465
|
const card = await getCard(ctx, { cardId, boardId })
|
|
468
466
|
if (!card) throw new Error(`Card not found: ${cardId}`)
|
|
469
467
|
|
|
470
468
|
const resolvedBoardId = card.boardId || ctx._resolveBoardId(boardId)
|
|
471
469
|
|
|
472
|
-
const payload = {
|
|
473
|
-
action,
|
|
474
|
-
board: resolvedBoardId,
|
|
475
|
-
list: card.status,
|
|
476
|
-
card: sanitizeCard(card),
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
const response = await fetch(actionWebhookUrl, {
|
|
480
|
-
method: 'POST',
|
|
481
|
-
headers: { 'Content-Type': 'application/json' },
|
|
482
|
-
body: JSON.stringify(payload),
|
|
483
|
-
})
|
|
484
|
-
|
|
485
|
-
if (!response.ok) {
|
|
486
|
-
throw new Error(`Action webhook responded with ${response.status}: ${response.statusText}`)
|
|
487
|
-
}
|
|
488
470
|
await appendActivityLog(ctx, {
|
|
489
471
|
cardId,
|
|
490
472
|
boardId: resolvedBoardId,
|
|
@@ -494,6 +476,13 @@ export async function triggerAction(
|
|
|
494
476
|
action,
|
|
495
477
|
},
|
|
496
478
|
}).catch(() => {})
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
action,
|
|
482
|
+
board: resolvedBoardId,
|
|
483
|
+
list: card.status,
|
|
484
|
+
card: sanitizeCard(card),
|
|
485
|
+
}
|
|
497
486
|
}
|
|
498
487
|
|
|
499
488
|
/**
|
package/src/sdk/plugins/index.ts
CHANGED
|
@@ -138,6 +138,8 @@ interface AuthPluginModule {
|
|
|
138
138
|
readonly createRbacIdentityPlugin?: unknown
|
|
139
139
|
/** Optional factory for a configurable policy plugin. When present it is called with the provider options from `.kanban.json` so plugins can apply per-deployment overrides such as a custom RBAC matrix. */
|
|
140
140
|
readonly createAuthPolicyPlugin?: ((options?: Record<string, unknown>) => unknown) | unknown
|
|
141
|
+
/** Optional factory for a configurable identity plugin. When present it is called with the provider options from `.kanban.json` so plugins can apply per-deployment overrides such as an explicit API token. */
|
|
142
|
+
readonly createAuthIdentityPlugin?: ((options?: Record<string, unknown>) => unknown) | unknown
|
|
141
143
|
readonly default?: unknown
|
|
142
144
|
}
|
|
143
145
|
|
|
@@ -345,7 +347,7 @@ function resolveAuthIdentityPlugin(ref: ProviderRef): AuthIdentityPlugin {
|
|
|
345
347
|
if (ref.provider === 'noop') return NOOP_IDENTITY_PLUGIN
|
|
346
348
|
if (ref.provider === 'rbac') return RBAC_IDENTITY_PLUGIN
|
|
347
349
|
const packageName = AUTH_PROVIDER_ALIASES.get(ref.provider) ?? ref.provider
|
|
348
|
-
return loadExternalAuthIdentityPlugin(packageName, ref.provider)
|
|
350
|
+
return loadExternalAuthIdentityPlugin(packageName, ref.provider, ref.options)
|
|
349
351
|
}
|
|
350
352
|
|
|
351
353
|
function resolveAuthPolicyPlugin(ref: ProviderRef): AuthPolicyPlugin {
|
|
@@ -1087,8 +1089,14 @@ function selectAuthPolicyPlugin(mod: AuthPluginModule, providerId: string): Auth
|
|
|
1087
1089
|
return null
|
|
1088
1090
|
}
|
|
1089
1091
|
|
|
1090
|
-
function loadExternalAuthIdentityPlugin(packageName: string, providerId: string): AuthIdentityPlugin {
|
|
1092
|
+
function loadExternalAuthIdentityPlugin(packageName: string, providerId: string, options?: Record<string, unknown>): AuthIdentityPlugin {
|
|
1091
1093
|
const mod = loadExternalModule(packageName) as AuthPluginModule
|
|
1094
|
+
|
|
1095
|
+
if (options !== undefined && typeof mod.createAuthIdentityPlugin === 'function') {
|
|
1096
|
+
const created = (mod.createAuthIdentityPlugin as (opts?: Record<string, unknown>) => unknown)(options)
|
|
1097
|
+
if (isValidAuthIdentityPlugin(created, providerId)) return created
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1092
1100
|
const plugin = selectAuthIdentityPlugin(mod, providerId)
|
|
1093
1101
|
if (!plugin) {
|
|
1094
1102
|
throw new Error(
|
package/src/sdk/types.d.ts
CHANGED
|
@@ -58,7 +58,7 @@ export type SDKBeforeEventType = 'card.create' | 'card.update' | 'card.move' | '
|
|
|
58
58
|
*
|
|
59
59
|
* @see AfterEventPayload for the payload envelope passed to after-event listeners.
|
|
60
60
|
*/
|
|
61
|
-
export type SDKAfterEventType = 'task.created' | 'task.updated' | 'task.moved' | 'task.deleted' | 'comment.created' | 'comment.updated' | 'comment.deleted' | 'column.created' | 'column.updated' | 'column.deleted' | 'attachment.added' | 'attachment.removed' | 'settings.updated' | 'board.created' | 'board.updated' | 'board.deleted' | 'board.action' | 'board.log.added' | 'board.log.cleared' | 'log.added' | 'log.cleared' | 'storage.migrated' | 'form.submitted' | 'auth.allowed' | 'auth.denied';
|
|
61
|
+
export type SDKAfterEventType = 'task.created' | 'task.updated' | 'task.moved' | 'task.deleted' | 'comment.created' | 'comment.updated' | 'comment.deleted' | 'column.created' | 'column.updated' | 'column.deleted' | 'attachment.added' | 'attachment.removed' | 'settings.updated' | 'board.created' | 'board.updated' | 'board.deleted' | 'board.action' | 'card.action.triggered' | 'board.log.added' | 'board.log.cleared' | 'log.added' | 'log.cleared' | 'storage.migrated' | 'form.submitted' | 'auth.allowed' | 'auth.denied';
|
|
62
62
|
/**
|
|
63
63
|
* Union of all SDK event types (before-events and after-events).
|
|
64
64
|
*
|
package/src/sdk/types.ts
CHANGED
package/src/sdk/webhooks.ts
CHANGED
|
@@ -32,10 +32,15 @@ import type { SDKEventType } from './types'
|
|
|
32
32
|
export function fireWebhooks(workspaceRoot: string, event: SDKEventType, data: unknown): void {
|
|
33
33
|
const config = readConfig(workspaceRoot)
|
|
34
34
|
const webhooks = config.webhooks || []
|
|
35
|
+
console.log(`[kanban-lite/webhooks] fireWebhooks: event=${event} | total=${webhooks.length} registered`)
|
|
35
36
|
const matching = webhooks.filter(
|
|
36
37
|
w => w.active && (w.events.includes('*') || w.events.includes(event))
|
|
37
38
|
)
|
|
38
|
-
if (matching.length === 0)
|
|
39
|
+
if (matching.length === 0) {
|
|
40
|
+
console.log(`[kanban-lite/webhooks] no matching webhooks for event=${event} (${webhooks.filter(w => !w.active).length} inactive)`)
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
console.log(`[kanban-lite/webhooks] firing ${matching.length} webhook(s) for event=${event}: ${matching.map(w => w.id).join(', ')}`)
|
|
39
44
|
|
|
40
45
|
const payload = JSON.stringify({
|
|
41
46
|
event,
|
|
@@ -73,6 +78,7 @@ async function deliverWebhook(webhook: Webhook, event: string, payload: string):
|
|
|
73
78
|
'X-Webhook-Event': event
|
|
74
79
|
}
|
|
75
80
|
|
|
81
|
+
const hasSecret = !!webhook.secret
|
|
76
82
|
if (webhook.secret) {
|
|
77
83
|
const signature = crypto
|
|
78
84
|
.createHmac('sha256', webhook.secret)
|
|
@@ -81,6 +87,10 @@ async function deliverWebhook(webhook: Webhook, event: string, payload: string):
|
|
|
81
87
|
headers['X-Webhook-Signature'] = `sha256=${signature}`
|
|
82
88
|
}
|
|
83
89
|
|
|
90
|
+
console.log(
|
|
91
|
+
`[kanban-lite/webhooks] → POST ${webhook.url} | event=${event} | id=${webhook.id} | secret=${hasSecret ? 'yes' : 'no'} | payloadBytes=${Buffer.byteLength(payload)}`,
|
|
92
|
+
)
|
|
93
|
+
|
|
84
94
|
return new Promise((resolve, reject) => {
|
|
85
95
|
const req = transport.request(
|
|
86
96
|
{
|
|
@@ -92,6 +102,9 @@ async function deliverWebhook(webhook: Webhook, event: string, payload: string):
|
|
|
92
102
|
timeout: 10000
|
|
93
103
|
},
|
|
94
104
|
(res) => {
|
|
105
|
+
console.log(
|
|
106
|
+
`[kanban-lite/webhooks] ← ${res.statusCode} ${res.statusMessage ?? ''} | id=${webhook.id} | url=${webhook.url}`,
|
|
107
|
+
)
|
|
95
108
|
res.resume() // drain response
|
|
96
109
|
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
|
97
110
|
resolve()
|
|
@@ -100,8 +113,12 @@ async function deliverWebhook(webhook: Webhook, event: string, payload: string):
|
|
|
100
113
|
}
|
|
101
114
|
}
|
|
102
115
|
)
|
|
103
|
-
req.on('error',
|
|
116
|
+
req.on('error', (err) => {
|
|
117
|
+
console.error(`[kanban-lite/webhooks] request error | id=${webhook.id} | url=${webhook.url}:`, err.message)
|
|
118
|
+
reject(err)
|
|
119
|
+
})
|
|
104
120
|
req.on('timeout', () => {
|
|
121
|
+
console.error(`[kanban-lite/webhooks] timeout | id=${webhook.id} | url=${webhook.url}`)
|
|
105
122
|
req.destroy()
|
|
106
123
|
reject(new Error('Timeout'))
|
|
107
124
|
})
|
package/src/shared/config.ts
CHANGED
|
@@ -191,7 +191,10 @@ export interface KanbanConfig {
|
|
|
191
191
|
webhooks?: Webhook[]
|
|
192
192
|
/** Label definitions keyed by label name, with color and optional group. */
|
|
193
193
|
labels?: Record<string, LabelDefinition>
|
|
194
|
-
/**
|
|
194
|
+
/**
|
|
195
|
+
* @deprecated Removed in favour of the webhook plugin. Register a webhook
|
|
196
|
+
* for the `card.action.triggered` event instead.
|
|
197
|
+
*/
|
|
195
198
|
actionWebhookUrl?: string
|
|
196
199
|
/**
|
|
197
200
|
* Global auto-increment card ID counter shared across all boards.
|
|
@@ -271,6 +274,13 @@ export interface KanbanConfig {
|
|
|
271
274
|
customHeadHtml?: string
|
|
272
275
|
/** Path to an HTML file (relative to workspace root) whose content is injected into the standalone board's `<head>` element. Takes precedence over `customHeadHtml`. */
|
|
273
276
|
customHeadHtmlFile?: string
|
|
277
|
+
/**
|
|
278
|
+
* Optional URL base path prefix for subfolder reverse-proxy deployments
|
|
279
|
+
* (e.g. `'/kanban'`). Must start with `/` and have no trailing slash.
|
|
280
|
+
* When set, all asset URLs, the WebSocket endpoint, and API routes are
|
|
281
|
+
* served under this prefix. Only applies to the standalone server.
|
|
282
|
+
*/
|
|
283
|
+
basePath?: string
|
|
274
284
|
}
|
|
275
285
|
|
|
276
286
|
// Legacy v1 config (for migration)
|
|
@@ -419,11 +429,99 @@ function migrateConfigV1ToV2(raw: Record<string, unknown>): KanbanConfig {
|
|
|
419
429
|
return v2
|
|
420
430
|
}
|
|
421
431
|
|
|
432
|
+
/**
|
|
433
|
+
* Loads key–value pairs from a `.env` file in the given directory into
|
|
434
|
+
* `process.env`. Existing environment variables are never overwritten so that
|
|
435
|
+
* real OS-level values always take precedence over file-based defaults.
|
|
436
|
+
* Silently does nothing if the file does not exist.
|
|
437
|
+
*
|
|
438
|
+
* @param dir - Directory that may contain a `.env` file.
|
|
439
|
+
*/
|
|
440
|
+
function loadDotEnv(dir: string): void {
|
|
441
|
+
const envPath = path.join(dir, '.env')
|
|
442
|
+
let content: string
|
|
443
|
+
try {
|
|
444
|
+
content = fs.readFileSync(envPath, 'utf-8')
|
|
445
|
+
} catch {
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
for (const line of content.split('\n')) {
|
|
449
|
+
const trimmed = line.trim()
|
|
450
|
+
if (!trimmed || trimmed.startsWith('#')) continue
|
|
451
|
+
const eqIdx = trimmed.indexOf('=')
|
|
452
|
+
if (eqIdx < 1) continue
|
|
453
|
+
const key = trimmed.slice(0, eqIdx).trim()
|
|
454
|
+
let val = trimmed.slice(eqIdx + 1).trim()
|
|
455
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
456
|
+
val = val.slice(1, -1)
|
|
457
|
+
}
|
|
458
|
+
if (process.env[key] === undefined) {
|
|
459
|
+
process.env[key] = val
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Recursively resolves `${VAR_NAME}` placeholders in all string values of a
|
|
466
|
+
* parsed config object against `process.env`. Mutates the object in place.
|
|
467
|
+
*
|
|
468
|
+
* Throws a descriptive error when a referenced environment variable is not
|
|
469
|
+
* set, including the JSON path to the offending value so the operator can
|
|
470
|
+
* locate it quickly. Example error message:
|
|
471
|
+
*
|
|
472
|
+
* ```
|
|
473
|
+
* missing ALICE_PASSWORD_HASH in .kanban.json: .plugins."auth.identity".options.users[3].password "${ALICE_PASSWORD_HASH}"
|
|
474
|
+
* ```
|
|
475
|
+
*
|
|
476
|
+
* Keys that contain non-identifier characters (e.g. dots) are quoted in the
|
|
477
|
+
* path segment, matching the convention used in `.kanban.json` error messages.
|
|
478
|
+
*
|
|
479
|
+
* @param node - The current node to process (object, array, string, or scalar).
|
|
480
|
+
* @param configFileName - Config filename used in error messages (e.g. `'.kanban.json'`).
|
|
481
|
+
* @param nodePath - JSON path accumulated so far (empty string at root).
|
|
482
|
+
* @returns The processed node (same reference for objects/arrays; new primitive for strings).
|
|
483
|
+
*/
|
|
484
|
+
function resolveConfigEnvVars(node: unknown, configFileName: string, nodePath = ''): unknown {
|
|
485
|
+
if (typeof node === 'string') {
|
|
486
|
+
return node.replace(/\$\{([^}]+)\}/g, (_match, varName: string) => {
|
|
487
|
+
const envValue = process.env[varName]
|
|
488
|
+
if (envValue === undefined) {
|
|
489
|
+
throw new Error(
|
|
490
|
+
`missing ${varName} in ${configFileName}: ${nodePath} "${node}"`
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
return envValue
|
|
494
|
+
})
|
|
495
|
+
}
|
|
496
|
+
if (Array.isArray(node)) {
|
|
497
|
+
for (let i = 0; i < node.length; i++) {
|
|
498
|
+
node[i] = resolveConfigEnvVars(node[i], configFileName, `${nodePath}[${i}]`)
|
|
499
|
+
}
|
|
500
|
+
return node
|
|
501
|
+
}
|
|
502
|
+
if (node !== null && typeof node === 'object') {
|
|
503
|
+
const obj = node as Record<string, unknown>
|
|
504
|
+
for (const key of Object.keys(obj)) {
|
|
505
|
+
const jsonKey = /[^a-zA-Z0-9_]/.test(key) ? `"${key}"` : key
|
|
506
|
+
const childPath = nodePath ? `${nodePath}.${jsonKey}` : `.${jsonKey}`
|
|
507
|
+
obj[key] = resolveConfigEnvVars(obj[key], configFileName, childPath)
|
|
508
|
+
}
|
|
509
|
+
return obj
|
|
510
|
+
}
|
|
511
|
+
return node
|
|
512
|
+
}
|
|
513
|
+
|
|
422
514
|
/**
|
|
423
515
|
* Reads the kanban config from disk. If the file is missing or unreadable,
|
|
424
516
|
* returns the default config. If the file contains a v1 config, it is
|
|
425
517
|
* automatically migrated to v2 format and persisted back to disk.
|
|
426
518
|
*
|
|
519
|
+
* Any `${VAR_NAME}` placeholders found in string values are resolved against
|
|
520
|
+
* `process.env` before the config is returned. If a referenced environment
|
|
521
|
+
* variable is not set the process will throw a descriptive error rather than
|
|
522
|
+
* silently falling back to defaults, because an unresolved secret is never a
|
|
523
|
+
* safe default.
|
|
524
|
+
*
|
|
427
525
|
* @param workspaceRoot - Absolute path to the workspace root directory.
|
|
428
526
|
* @returns The parsed (and possibly migrated) kanban configuration.
|
|
429
527
|
*
|
|
@@ -434,8 +532,32 @@ function migrateConfigV1ToV2(raw: Record<string, unknown>): KanbanConfig {
|
|
|
434
532
|
export function readConfig(workspaceRoot: string): KanbanConfig {
|
|
435
533
|
const filePath = configPath(workspaceRoot)
|
|
436
534
|
const defaults = { ...DEFAULT_CONFIG, boards: { default: { ...DEFAULT_BOARD_CONFIG, columns: [...DEFAULT_COLUMNS] } } }
|
|
535
|
+
|
|
536
|
+
// Parse the file first; fall back to defaults only for read/parse failures.
|
|
537
|
+
let raw: Record<string, unknown>
|
|
538
|
+
try {
|
|
539
|
+
raw = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
|
|
540
|
+
} catch {
|
|
541
|
+
return defaults
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Load .env from workspace root before resolving placeholders so that
|
|
545
|
+
// variables defined there are available without requiring the operator to
|
|
546
|
+
// export them in their shell.
|
|
547
|
+
loadDotEnv(workspaceRoot)
|
|
548
|
+
|
|
549
|
+
// Resolve ${VAR_NAME} env placeholders. A missing env variable is a known,
|
|
550
|
+
// operator-caused misconfiguration and should produce a clean, actionable
|
|
551
|
+
// error rather than a Node.js stack trace.
|
|
552
|
+
try {
|
|
553
|
+
resolveConfigEnvVars(raw, CONFIG_FILENAME)
|
|
554
|
+
} catch (err) {
|
|
555
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
556
|
+
process.stderr.write(`\nConfiguration error: ${msg}\n\nSet the missing environment variable before starting the server.\n\n`)
|
|
557
|
+
process.exit(1)
|
|
558
|
+
}
|
|
559
|
+
|
|
437
560
|
try {
|
|
438
|
-
const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
|
|
439
561
|
// True v1: explicitly version 1, OR version absent AND no boards object
|
|
440
562
|
// A versionless modern config (has a boards object) must NOT be treated as v1
|
|
441
563
|
const isV1 = raw.version === 1 || (!raw.version && !(typeof raw.boards === 'object' && raw.boards !== null && !Array.isArray(raw.boards)))
|
|
@@ -6,20 +6,25 @@ import { KanbanSDK } from '../../sdk/KanbanSDK'
|
|
|
6
6
|
import type { StandaloneContext } from '../context'
|
|
7
7
|
import { broadcastLogsUpdatedToEditingClients, getClientsEditingCard } from '../broadcastService'
|
|
8
8
|
|
|
9
|
-
export
|
|
9
|
+
export function getIndexHtml(basePath = ''): string {
|
|
10
|
+
return `<!DOCTYPE html>
|
|
10
11
|
<html lang="en">
|
|
11
12
|
<head>
|
|
12
13
|
<meta charset="UTF-8">
|
|
13
14
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
14
|
-
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
15
|
-
<link href="/style.css" rel="stylesheet">
|
|
15
|
+
<link rel="icon" type="image/svg+xml" href="${basePath}/favicon.svg">
|
|
16
|
+
<link href="${basePath}/style.css" rel="stylesheet">
|
|
16
17
|
<title>Kanban Board</title>
|
|
18
|
+
<script>window.__KB_BASE__ = ${JSON.stringify(basePath)}<\/script>
|
|
17
19
|
</head>
|
|
18
20
|
<body>
|
|
19
21
|
<div id="root"></div>
|
|
20
|
-
<script type="module" src="/index.js"
|
|
22
|
+
<script type="module" src="${basePath}/index.js"><\/script>
|
|
21
23
|
</body>
|
|
22
24
|
</html>`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const indexHtml = getIndexHtml()
|
|
23
28
|
|
|
24
29
|
export interface StandaloneRuntime {
|
|
25
30
|
absoluteKanbanDir: string
|
|
@@ -46,13 +51,13 @@ function resolveStandaloneWebviewDir(webviewDir?: string): string {
|
|
|
46
51
|
return candidates[0]
|
|
47
52
|
}
|
|
48
53
|
|
|
49
|
-
export function createStandaloneRuntime(kanbanDir: string, webviewDir?: string, httpServer?: http.Server): StandaloneRuntime {
|
|
54
|
+
export function createStandaloneRuntime(kanbanDir: string, webviewDir?: string, httpServer?: http.Server, basePath?: string): StandaloneRuntime {
|
|
50
55
|
const absoluteKanbanDir = path.resolve(kanbanDir)
|
|
51
56
|
const workspaceRoot = path.dirname(absoluteKanbanDir)
|
|
52
57
|
const resolvedWebviewDir = resolveStandaloneWebviewDir(webviewDir)
|
|
53
58
|
|
|
54
59
|
const server = httpServer ?? http.createServer()
|
|
55
|
-
const wss = new WebSocketServer({ server, path: '/ws' })
|
|
60
|
+
const wss = new WebSocketServer({ server, path: (basePath || '') + '/ws' })
|
|
56
61
|
|
|
57
62
|
const ctx = {} as StandaloneContext
|
|
58
63
|
const sdk = new KanbanSDK(absoluteKanbanDir, {
|
package/src/standalone/server.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { StandaloneHttpHandler, StandaloneHttpPlugin } from '../sdk'
|
|
|
9
9
|
import { createRouteMatcher, type StandaloneRequestContext, type StandaloneRouteHandler } from './internal/common'
|
|
10
10
|
import { KANBAN_OPENAPI_SPEC } from './internal/openapi-spec'
|
|
11
11
|
import { handleCardFileRoute, setupStandaloneLifecycle } from './internal/lifecycle'
|
|
12
|
-
import { createStandaloneRuntime,
|
|
12
|
+
import { createStandaloneRuntime, getIndexHtml } from './internal/runtime'
|
|
13
13
|
import { handleBoardRoutes } from './internal/routes/boards'
|
|
14
14
|
import { handleSystemRoutes } from './internal/routes/system'
|
|
15
15
|
import { handleTaskRoutes } from './internal/routes/tasks'
|
|
@@ -249,11 +249,15 @@ export function startServer(kanbanDir: string, port: number, webviewDir?: string
|
|
|
249
249
|
? { type: 'image/svg+xml', content: fs.readFileSync(swaggerUiLogoPath) }
|
|
250
250
|
: null
|
|
251
251
|
|
|
252
|
-
const
|
|
252
|
+
const workspaceRoot = path.dirname(path.resolve(kanbanDir))
|
|
253
|
+
const config = readConfig(workspaceRoot)
|
|
254
|
+
const rawBase = config.basePath ?? ''
|
|
255
|
+
const basePath = rawBase ? (rawBase.startsWith('/') ? rawBase : '/' + rawBase).replace(/\/+$/, '') : ''
|
|
256
|
+
|
|
257
|
+
const runtime = createStandaloneRuntime(kanbanDir, webviewDir, fastify.server, basePath)
|
|
253
258
|
const { ctx, resolvedWebviewDir } = runtime
|
|
254
259
|
|
|
255
|
-
|
|
256
|
-
let resolvedIndexHtml = indexHtml
|
|
260
|
+
let resolvedIndexHtml = getIndexHtml(basePath)
|
|
257
261
|
let customHead = config.customHeadHtml || ''
|
|
258
262
|
if (config.customHeadHtmlFile) {
|
|
259
263
|
try {
|
|
@@ -262,7 +266,7 @@ export function startServer(kanbanDir: string, port: number, webviewDir?: string
|
|
|
262
266
|
} catch { /* file not found, fall back to customHeadHtml */ }
|
|
263
267
|
}
|
|
264
268
|
if (customHead) {
|
|
265
|
-
resolvedIndexHtml =
|
|
269
|
+
resolvedIndexHtml = resolvedIndexHtml.replace('</head>', `${customHead}\n</head>`)
|
|
266
270
|
}
|
|
267
271
|
const standaloneHttpPlugins = ctx.sdk.capabilities?.standaloneHttpPlugins ?? []
|
|
268
272
|
const standaloneOpenApiSpec = buildStandaloneOpenApiSpec(standaloneHttpPlugins)
|
|
@@ -270,7 +274,7 @@ export function startServer(kanbanDir: string, port: number, webviewDir?: string
|
|
|
270
274
|
// OpenAPI spec and interactive docs (served before the catch-all so Fastify prefers these routes)
|
|
271
275
|
fastify.register(swagger, { openapi: standaloneOpenApiSpec as any })
|
|
272
276
|
fastify.register(swaggerUi, {
|
|
273
|
-
routePrefix:
|
|
277
|
+
routePrefix: `${basePath}/api/docs`,
|
|
274
278
|
uiConfig: { docExpansion: 'list', deepLinking: false },
|
|
275
279
|
logo: swaggerUiLogo,
|
|
276
280
|
...(swaggerUiStaticDir ? { baseDir: swaggerUiStaticDir } : {}),
|
|
@@ -313,6 +317,16 @@ export function startServer(kanbanDir: string, port: number, webviewDir?: string
|
|
|
313
317
|
req._rawBody = request.body
|
|
314
318
|
}
|
|
315
319
|
|
|
320
|
+
// Strip base path prefix so internal route handlers see root-relative paths
|
|
321
|
+
if (basePath) {
|
|
322
|
+
const rawUrl = req.url ?? '/'
|
|
323
|
+
if (rawUrl === basePath) {
|
|
324
|
+
req.url = '/'
|
|
325
|
+
} else if (rawUrl.startsWith(basePath + '/') || rawUrl.startsWith(basePath + '?')) {
|
|
326
|
+
req.url = rawUrl.slice(basePath.length)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
316
330
|
const requestContext = createRequestContext(ctx, req, reply.raw, resolvedWebviewDir, resolvedIndexHtml)
|
|
317
331
|
|
|
318
332
|
await dispatchRequest(requestContext, middlewareHandlers)
|
|
@@ -334,7 +348,7 @@ export function startServer(kanbanDir: string, port: number, webviewDir?: string
|
|
|
334
348
|
console.error('Failed to start server:', err)
|
|
335
349
|
process.exit(1)
|
|
336
350
|
}
|
|
337
|
-
console.log(`Kanban board running at http://localhost:${port}`)
|
|
351
|
+
console.log(`Kanban board running at http://localhost:${port}${basePath}`)
|
|
338
352
|
console.log(`API available at http://localhost:${port}/api`)
|
|
339
353
|
console.log(`Kanban config: ${effectiveConfigPath}`)
|
|
340
354
|
console.log(`Kanban directory: ${ctx.absoluteKanbanDir}`)
|
|
@@ -110,4 +110,13 @@ export function setupWatcher(ctx: StandaloneContext, server: http.Server): void
|
|
|
110
110
|
ctx.wss.close()
|
|
111
111
|
})
|
|
112
112
|
}
|
|
113
|
+
|
|
114
|
+
// Watch .kanban.json for config changes and re-broadcast init on change
|
|
115
|
+
const configFilePath = path.join(ctx.workspaceRoot, '.kanban.json')
|
|
116
|
+
const configWatcher = chokidar.watch(configFilePath, {
|
|
117
|
+
ignoreInitial: true,
|
|
118
|
+
awaitWriteFinish: { stabilityThreshold: 100 }
|
|
119
|
+
})
|
|
120
|
+
configWatcher.on('change', () => handleFileChange(ctx, debounceRef))
|
|
121
|
+
server.on('close', () => configWatcher.close())
|
|
113
122
|
}
|
|
@@ -6,7 +6,8 @@ import type { ConnectionStatusMessage, ExtensionMessage, WebviewMessage } from '
|
|
|
6
6
|
|
|
7
7
|
if (!('acquireVsCodeApi' in window)) {
|
|
8
8
|
|
|
9
|
-
const
|
|
9
|
+
const kbBase = (window as unknown as { __KB_BASE__?: string }).__KB_BASE__ ?? ''
|
|
10
|
+
const WS_URL = `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}${kbBase}/ws`
|
|
10
11
|
const RECONNECT_DELAYS_MS = [250, 500, 1000, 2000, 4000] as const
|
|
11
12
|
const MAX_RETRIES = RECONNECT_DELAYS_MS.length
|
|
12
13
|
|