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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kanban-lite",
3
- "version": "1.2.2",
3
+ "version": "1.2.3",
4
4
  "description": "Kanban board manager - VSCode extension, CLI, MCP server, and standalone web app",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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. Calls the configured action webhook URL with the action name and card details.',
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'),
@@ -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 by POSTing to the global `actionWebhookUrl`
962
- * configured in `.kanban.json`.
961
+ * Triggers a named action for a card.
963
962
  *
964
- * The payload sent to the webhook is:
965
- * ```json
966
- * { "action": "retry", "board": "default", "list": "in-progress", "card": { ...sanitizedCard } }
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 resolving when the webhook responds with 2xx.
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
@@ -1737,21 +1737,17 @@ export class KanbanSDK {
1737
1737
  }
1738
1738
 
1739
1739
  /**
1740
- * Triggers a named action for a card by POSTing to the global `actionWebhookUrl`
1741
- * configured in `.kanban.json`.
1740
+ * Triggers a named action for a card.
1742
1741
  *
1743
- * The payload sent to the webhook is:
1744
- * ```json
1745
- * { "action": "retry", "board": "default", "list": "in-progress", "card": { ...sanitizedCard } }
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 resolving when the webhook responds with 2xx.
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
- return Cards.triggerAction(this, mergedInput)
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
@@ -452,39 +452,21 @@ export async function updateCard(
452
452
  }
453
453
 
454
454
  /**
455
- * Triggers a named action for a card by POSTing to the global `actionWebhookUrl`.
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<void> {
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
  /**
@@ -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(
@@ -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
@@ -118,6 +118,7 @@ export type SDKAfterEventType =
118
118
  | 'board.updated'
119
119
  | 'board.deleted'
120
120
  | 'board.action'
121
+ | 'card.action.triggered'
121
122
  | 'board.log.added'
122
123
  | 'board.log.cleared'
123
124
  | 'log.added'
@@ -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) return
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', reject)
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
  })
@@ -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
- /** Optional URL to POST to when a card action is triggered. */
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 const indexHtml = `<!DOCTYPE html>
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"></script>
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, {
@@ -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, indexHtml } from './internal/runtime'
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 runtime = createStandaloneRuntime(kanbanDir, webviewDir, fastify.server)
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
- const config = readConfig(ctx.workspaceRoot)
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 = indexHtml.replace('</head>', `${customHead}\n</head>`)
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: '/api/docs',
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 WS_URL = `ws://${window.location.host}/ws`
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