kanban-lite 1.2.2 → 1.2.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/dist/cli.js +257 -90
- package/dist/extension.js +245 -78
- package/dist/mcp-server.js +117 -33
- package/dist/sdk/index.cjs +117 -32
- package/dist/sdk/index.mjs +117 -32
- package/dist/sdk/sdk/KanbanSDK.d.ts +27 -10
- package/dist/sdk/sdk/modules/cards.d.ts +11 -2
- package/dist/sdk/sdk/plugins/index.d.ts +7 -0
- package/dist/sdk/sdk/types.d.ts +12 -27
- package/dist/sdk/shared/config.d.ts +17 -1
- package/dist/standalone-webview/index.js +38 -38
- package/dist/standalone-webview/index.js.map +1 -1
- package/dist/standalone.js +307 -125
- package/package.json +1 -1
- package/src/cli/index.test.ts +157 -0
- package/src/cli/index.ts +1 -1
- package/src/mcp-server/index.test.ts +76 -0
- package/src/mcp-server/index.ts +1 -1
- package/src/sdk/KanbanSDK.d.ts +26 -10
- package/src/sdk/KanbanSDK.ts +37 -11
- package/src/sdk/__tests__/KanbanSDK.test.ts +79 -24
- package/src/sdk/integrationCatalog.ts +1 -0
- package/src/sdk/modules/cards.ts +13 -24
- package/src/sdk/plugins/index.d.ts +7 -0
- package/src/sdk/plugins/index.ts +17 -2
- package/src/sdk/types.d.ts +10 -26
- package/src/sdk/types.ts +11 -24
- package/src/sdk/webhooks.ts +19 -2
- package/src/shared/config.ts +130 -2
- package/src/standalone/__tests__/server.integration.test.ts +81 -2
- package/src/standalone/internal/runtime.ts +11 -6
- package/src/standalone/internal/websocket.ts +13 -3
- package/src/standalone/server.ts +67 -9
- package/src/standalone/watcherSetup.ts +9 -0
- package/src/webview/standalone-shim.ts +2 -1
- package/tmp/screenshots-workspace/.kanban/.active-card.json +5 -0
- package/tmp/screenshots-workspace/.kanban/boards/default/deleted/1-dddd.md +17 -0
- package/tmp/screenshots-workspace/.kanban/boards/default/deleted/attachments/1.log +1 -0
- package/tmp/screenshots-workspace/.kanban.json +59 -0
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 {
|
|
@@ -621,6 +623,13 @@ export type StandaloneHttpHandler = (request: StandaloneHttpRequestContext) => P
|
|
|
621
623
|
* resolved the active workspace capability selections.
|
|
622
624
|
*/
|
|
623
625
|
export interface StandaloneHttpPluginRegistrationOptions {
|
|
626
|
+
/**
|
|
627
|
+
* Active SDK instance backing the standalone runtime, when provided by the host.
|
|
628
|
+
*
|
|
629
|
+
* Plugin registration code may use the full public {@link KanbanSDK} surface,
|
|
630
|
+
* including `getConfigSnapshot()`, when this seam is available.
|
|
631
|
+
*/
|
|
632
|
+
readonly sdk?: KanbanSDK
|
|
624
633
|
/** Absolute workspace root containing `.kanban.json`. */
|
|
625
634
|
readonly workspaceRoot: string
|
|
626
635
|
/** Absolute workspace `.kanban` directory. */
|
|
@@ -1087,8 +1096,14 @@ function selectAuthPolicyPlugin(mod: AuthPluginModule, providerId: string): Auth
|
|
|
1087
1096
|
return null
|
|
1088
1097
|
}
|
|
1089
1098
|
|
|
1090
|
-
function loadExternalAuthIdentityPlugin(packageName: string, providerId: string): AuthIdentityPlugin {
|
|
1099
|
+
function loadExternalAuthIdentityPlugin(packageName: string, providerId: string, options?: Record<string, unknown>): AuthIdentityPlugin {
|
|
1091
1100
|
const mod = loadExternalModule(packageName) as AuthPluginModule
|
|
1101
|
+
|
|
1102
|
+
if (options !== undefined && typeof mod.createAuthIdentityPlugin === 'function') {
|
|
1103
|
+
const created = (mod.createAuthIdentityPlugin as (opts?: Record<string, unknown>) => unknown)(options)
|
|
1104
|
+
if (isValidAuthIdentityPlugin(created, providerId)) return created
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1092
1107
|
const plugin = selectAuthIdentityPlugin(mod, providerId)
|
|
1093
1108
|
if (!plugin) {
|
|
1094
1109
|
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
|
*
|
|
@@ -468,29 +468,11 @@ export declare class CardStateError extends Error {
|
|
|
468
468
|
constructor(code: CardStateErrorCode, message: string);
|
|
469
469
|
}
|
|
470
470
|
/**
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
* importing `KanbanSDK` directly so they remain decoupled from core internals.
|
|
471
|
+
* @deprecated Use {@link KanbanSDK}. CLI plugin hosts now advertise the full
|
|
472
|
+
* public SDK surface, including `getConfigSnapshot()`, instead of a narrowed
|
|
473
|
+
* webhook-only facade.
|
|
475
474
|
*/
|
|
476
|
-
export
|
|
477
|
-
/**
|
|
478
|
-
* Returns the SDK extension bag contributed by the plugin with the given id,
|
|
479
|
-
* when the host is backed by a full `KanbanSDK` instance.
|
|
480
|
-
*
|
|
481
|
-
* CLI plugins should prefer this extension path when available and fall back
|
|
482
|
-
* to compatibility methods only when running against older or mocked SDK facades.
|
|
483
|
-
*/
|
|
484
|
-
getExtension?<T extends Record<string, unknown> = Record<string, unknown>>(id: string): T | undefined;
|
|
485
|
-
listWebhooks(): Webhook[];
|
|
486
|
-
createWebhook(input: {
|
|
487
|
-
url: string;
|
|
488
|
-
events: string[];
|
|
489
|
-
secret?: string;
|
|
490
|
-
}): Promise<Webhook>;
|
|
491
|
-
updateWebhook(id: string, updates: Partial<Pick<Webhook, 'url' | 'events' | 'secret' | 'active'>>): Promise<Webhook | null>;
|
|
492
|
-
deleteWebhook(id: string): Promise<boolean>;
|
|
493
|
-
}
|
|
475
|
+
export type CliPluginSdk = KanbanSDK;
|
|
494
476
|
/**
|
|
495
477
|
* Runtime context supplied to a {@link KanbanCliPlugin} when it is invoked by
|
|
496
478
|
* the `kl` CLI.
|
|
@@ -503,10 +485,12 @@ export interface CliPluginContext {
|
|
|
503
485
|
*
|
|
504
486
|
* Present when the plugin is invoked through the core `kl` CLI.
|
|
505
487
|
* Absent in isolated unit tests or standalone invocations.
|
|
506
|
-
|
|
507
|
-
|
|
488
|
+
* Plugins may use the full public {@link KanbanSDK} contract here, including
|
|
489
|
+
* extension lookup and `getConfigSnapshot()`, instead of relying on older
|
|
490
|
+
* helper-only SDK facades. Plugins should prefer this over constructing their
|
|
491
|
+
* own SDK so that SDK-level auth policy is honoured.
|
|
508
492
|
*/
|
|
509
|
-
sdk?:
|
|
493
|
+
sdk?: KanbanSDK;
|
|
510
494
|
/**
|
|
511
495
|
* Core-owned CLI auth helper.
|
|
512
496
|
*
|
package/src/sdk/types.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Card, CardFormAttachment, CardFormDataMap, Priority, ResolvedFormDescriptor } from '../shared/types'
|
|
2
2
|
import type { CapabilitySelections, Webhook } from '../shared/config'
|
|
3
|
+
import type { KanbanSDK } from './KanbanSDK'
|
|
3
4
|
import type { StorageEngine, StorageEngineType } from './plugins/types'
|
|
4
5
|
import type { CardStateCursor } from './plugins'
|
|
5
6
|
|
|
@@ -118,6 +119,7 @@ export type SDKAfterEventType =
|
|
|
118
119
|
| 'board.updated'
|
|
119
120
|
| 'board.deleted'
|
|
120
121
|
| 'board.action'
|
|
122
|
+
| 'card.action.triggered'
|
|
121
123
|
| 'board.log.added'
|
|
122
124
|
| 'board.log.cleared'
|
|
123
125
|
| 'log.added'
|
|
@@ -614,28 +616,11 @@ export class CardStateError extends Error {
|
|
|
614
616
|
}
|
|
615
617
|
|
|
616
618
|
/**
|
|
617
|
-
*
|
|
618
|
-
*
|
|
619
|
-
*
|
|
620
|
-
* importing `KanbanSDK` directly so they remain decoupled from core internals.
|
|
619
|
+
* @deprecated Use {@link KanbanSDK}. CLI plugin hosts now advertise the full
|
|
620
|
+
* public SDK surface, including `getConfigSnapshot()`, instead of a narrowed
|
|
621
|
+
* webhook-only facade.
|
|
621
622
|
*/
|
|
622
|
-
export
|
|
623
|
-
/**
|
|
624
|
-
* Returns the SDK extension bag contributed by the plugin with the given id,
|
|
625
|
-
* when the host is backed by a full `KanbanSDK` instance.
|
|
626
|
-
*
|
|
627
|
-
* CLI plugins should prefer this extension path when available and fall back
|
|
628
|
-
* to compatibility methods only when running against older or mocked SDK facades.
|
|
629
|
-
*/
|
|
630
|
-
getExtension?<T extends Record<string, unknown> = Record<string, unknown>>(id: string): T | undefined
|
|
631
|
-
listWebhooks(): Webhook[]
|
|
632
|
-
createWebhook(input: { url: string; events: string[]; secret?: string }): Promise<Webhook>
|
|
633
|
-
updateWebhook(
|
|
634
|
-
id: string,
|
|
635
|
-
updates: Partial<Pick<Webhook, 'url' | 'events' | 'secret' | 'active'>>,
|
|
636
|
-
): Promise<Webhook | null>
|
|
637
|
-
deleteWebhook(id: string): Promise<boolean>
|
|
638
|
-
}
|
|
623
|
+
export type CliPluginSdk = KanbanSDK
|
|
639
624
|
|
|
640
625
|
/**
|
|
641
626
|
* Runtime context supplied to a {@link KanbanCliPlugin} when it is invoked by
|
|
@@ -649,10 +634,12 @@ export interface CliPluginContext {
|
|
|
649
634
|
*
|
|
650
635
|
* Present when the plugin is invoked through the core `kl` CLI.
|
|
651
636
|
* Absent in isolated unit tests or standalone invocations.
|
|
652
|
-
* Plugins
|
|
653
|
-
*
|
|
637
|
+
* Plugins may use the full public {@link KanbanSDK} contract here, including
|
|
638
|
+
* extension lookup and `getConfigSnapshot()`, instead of relying on older
|
|
639
|
+
* helper-only SDK facades. Plugins should prefer this over constructing their
|
|
640
|
+
* own SDK so that SDK-level auth policy is honoured.
|
|
654
641
|
*/
|
|
655
|
-
sdk?:
|
|
642
|
+
sdk?: KanbanSDK
|
|
656
643
|
/**
|
|
657
644
|
* Core-owned CLI auth helper.
|
|
658
645
|
*
|
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,105 @@ 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
|
+
const isFormDefaultDataPath = /^\.forms\.(?:[^.]+|"[^"]+")\.data(?:$|[.\[])/.test(nodePath)
|
|
486
|
+
|
|
487
|
+
if (isFormDefaultDataPath) {
|
|
488
|
+
return node
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (typeof node === 'string') {
|
|
492
|
+
return node.replace(/\$\{([^}]+)\}/g, (_match, varName: string) => {
|
|
493
|
+
const envValue = process.env[varName]
|
|
494
|
+
if (envValue === undefined) {
|
|
495
|
+
throw new Error(
|
|
496
|
+
`missing ${varName} in ${configFileName}: ${nodePath} "${node}"`
|
|
497
|
+
)
|
|
498
|
+
}
|
|
499
|
+
return envValue
|
|
500
|
+
})
|
|
501
|
+
}
|
|
502
|
+
if (Array.isArray(node)) {
|
|
503
|
+
for (let i = 0; i < node.length; i++) {
|
|
504
|
+
node[i] = resolveConfigEnvVars(node[i], configFileName, `${nodePath}[${i}]`)
|
|
505
|
+
}
|
|
506
|
+
return node
|
|
507
|
+
}
|
|
508
|
+
if (node !== null && typeof node === 'object') {
|
|
509
|
+
const obj = node as Record<string, unknown>
|
|
510
|
+
for (const key of Object.keys(obj)) {
|
|
511
|
+
const jsonKey = /[^a-zA-Z0-9_]/.test(key) ? `"${key}"` : key
|
|
512
|
+
const childPath = nodePath ? `${nodePath}.${jsonKey}` : `.${jsonKey}`
|
|
513
|
+
obj[key] = resolveConfigEnvVars(obj[key], configFileName, childPath)
|
|
514
|
+
}
|
|
515
|
+
return obj
|
|
516
|
+
}
|
|
517
|
+
return node
|
|
518
|
+
}
|
|
519
|
+
|
|
422
520
|
/**
|
|
423
521
|
* Reads the kanban config from disk. If the file is missing or unreadable,
|
|
424
522
|
* returns the default config. If the file contains a v1 config, it is
|
|
425
523
|
* automatically migrated to v2 format and persisted back to disk.
|
|
426
524
|
*
|
|
525
|
+
* Any `${VAR_NAME}` placeholders found in string values are resolved against
|
|
526
|
+
* `process.env` before the config is returned. If a referenced environment
|
|
527
|
+
* variable is not set the process will throw a descriptive error rather than
|
|
528
|
+
* silently falling back to defaults, because an unresolved secret is never a
|
|
529
|
+
* safe default.
|
|
530
|
+
*
|
|
427
531
|
* @param workspaceRoot - Absolute path to the workspace root directory.
|
|
428
532
|
* @returns The parsed (and possibly migrated) kanban configuration.
|
|
429
533
|
*
|
|
@@ -434,8 +538,32 @@ function migrateConfigV1ToV2(raw: Record<string, unknown>): KanbanConfig {
|
|
|
434
538
|
export function readConfig(workspaceRoot: string): KanbanConfig {
|
|
435
539
|
const filePath = configPath(workspaceRoot)
|
|
436
540
|
const defaults = { ...DEFAULT_CONFIG, boards: { default: { ...DEFAULT_BOARD_CONFIG, columns: [...DEFAULT_COLUMNS] } } }
|
|
541
|
+
|
|
542
|
+
// Parse the file first; fall back to defaults only for read/parse failures.
|
|
543
|
+
let raw: Record<string, unknown>
|
|
544
|
+
try {
|
|
545
|
+
raw = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
|
|
546
|
+
} catch {
|
|
547
|
+
return defaults
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Load .env from workspace root before resolving placeholders so that
|
|
551
|
+
// variables defined there are available without requiring the operator to
|
|
552
|
+
// export them in their shell.
|
|
553
|
+
loadDotEnv(workspaceRoot)
|
|
554
|
+
|
|
555
|
+
// Resolve ${VAR_NAME} env placeholders. A missing env variable is a known,
|
|
556
|
+
// operator-caused misconfiguration and should produce a clean, actionable
|
|
557
|
+
// error rather than a Node.js stack trace.
|
|
558
|
+
try {
|
|
559
|
+
resolveConfigEnvVars(raw, CONFIG_FILENAME)
|
|
560
|
+
} catch (err) {
|
|
561
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
562
|
+
process.stderr.write(`\nConfiguration error: ${msg}\n\nSet the missing environment variable before starting the server.\n\n`)
|
|
563
|
+
process.exit(1)
|
|
564
|
+
}
|
|
565
|
+
|
|
437
566
|
try {
|
|
438
|
-
const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
|
|
439
567
|
// True v1: explicitly version 1, OR version absent AND no boards object
|
|
440
568
|
// A versionless modern config (has a boards object) must NOT be treated as v1
|
|
441
569
|
const isV1 = raw.version === 1 || (!raw.version && !(typeof raw.boards === 'object' && raw.boards !== null && !Array.isArray(raw.boards)))
|
|
@@ -350,6 +350,86 @@ describe('Standalone Server Integration', () => {
|
|
|
350
350
|
}
|
|
351
351
|
})
|
|
352
352
|
|
|
353
|
+
it('passes the resolved public SDK to standalone plugin registration and request contexts', async () => {
|
|
354
|
+
const cleanup = installTempPackage(
|
|
355
|
+
'standalone-sdk-context-test-plugin',
|
|
356
|
+
`module.exports = {
|
|
357
|
+
authIdentityPlugin: {
|
|
358
|
+
manifest: { id: 'standalone-sdk-context-test-plugin', provides: ['auth.identity'] },
|
|
359
|
+
async resolveIdentity() {
|
|
360
|
+
return { subject: 'standalone-sdk-context-test-plugin' }
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
standaloneHttpPlugin: {
|
|
364
|
+
manifest: { id: 'standalone-sdk-context-test-plugin', provides: ['standalone.http'] },
|
|
365
|
+
registerMiddleware() {
|
|
366
|
+
return [async (request) => {
|
|
367
|
+
if (!request.route('GET', '/api/plugin-sdk-context')) return false
|
|
368
|
+
request.mergeAuthContext({ actorHint: 'middleware-auth-context' })
|
|
369
|
+
return false
|
|
370
|
+
}]
|
|
371
|
+
},
|
|
372
|
+
registerRoutes(options) {
|
|
373
|
+
return [async (request) => {
|
|
374
|
+
if (!request.route('GET', '/api/plugin-sdk-context')) return false
|
|
375
|
+
const registrationSnapshot = options.sdk ? options.sdk.getConfigSnapshot() : null
|
|
376
|
+
const requestSnapshot = request.sdk.getConfigSnapshot()
|
|
377
|
+
const registrationBoard = options.sdk ? options.sdk.getBoard('default') : null
|
|
378
|
+
const requestBoard = request.sdk.getBoard('default')
|
|
379
|
+
request.res.statusCode = 200
|
|
380
|
+
request.res.setHeader('Content-Type', 'application/json')
|
|
381
|
+
request.res.end(JSON.stringify({
|
|
382
|
+
ok: true,
|
|
383
|
+
registrationSdkAvailable: !!options.sdk,
|
|
384
|
+
registrationPort: registrationSnapshot ? registrationSnapshot.port ?? null : null,
|
|
385
|
+
registrationBoardName: registrationBoard ? registrationBoard.name : null,
|
|
386
|
+
requestPort: requestSnapshot.port ?? null,
|
|
387
|
+
requestBoardName: requestBoard.name,
|
|
388
|
+
workspaceRootMatches: options.workspaceRoot === request.workspaceRoot,
|
|
389
|
+
kanbanDirMatches: options.kanbanDir === request.kanbanDir,
|
|
390
|
+
actorHint: request.getAuthContext().actorHint ?? null,
|
|
391
|
+
}))
|
|
392
|
+
return true
|
|
393
|
+
}]
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
}
|
|
397
|
+
`,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
const workspaceRoot = path.dirname(tempDir)
|
|
401
|
+
const resolvedConfigPath = writeWorkspaceConfig(workspaceRoot, {
|
|
402
|
+
port,
|
|
403
|
+
auth: {
|
|
404
|
+
'auth.identity': { provider: 'standalone-sdk-context-test-plugin' },
|
|
405
|
+
'auth.policy': { provider: 'noop' },
|
|
406
|
+
},
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
const localPort = await getPort()
|
|
410
|
+
const localServer = startServer(tempDir, localPort, webviewDir, resolvedConfigPath)
|
|
411
|
+
await sleep(200)
|
|
412
|
+
|
|
413
|
+
try {
|
|
414
|
+
const res = await httpGet(`http://localhost:${localPort}/api/plugin-sdk-context`)
|
|
415
|
+
expect(res.status).toBe(200)
|
|
416
|
+
expect(JSON.parse(res.body)).toEqual({
|
|
417
|
+
ok: true,
|
|
418
|
+
registrationSdkAvailable: true,
|
|
419
|
+
registrationPort: port,
|
|
420
|
+
registrationBoardName: 'Default',
|
|
421
|
+
requestPort: port,
|
|
422
|
+
requestBoardName: 'Default',
|
|
423
|
+
workspaceRootMatches: true,
|
|
424
|
+
kanbanDirMatches: true,
|
|
425
|
+
actorHint: 'middleware-auth-context',
|
|
426
|
+
})
|
|
427
|
+
} finally {
|
|
428
|
+
cleanup()
|
|
429
|
+
await new Promise<void>((resolve) => localServer.close(() => resolve()))
|
|
430
|
+
}
|
|
431
|
+
})
|
|
432
|
+
|
|
353
433
|
it('starts even when the Swagger UI package logo file is unavailable', async () => {
|
|
354
434
|
vi.resetModules()
|
|
355
435
|
|
|
@@ -2561,7 +2641,7 @@ describe('Standalone Server Integration', () => {
|
|
|
2561
2641
|
const kanbanJson = path.join(path.dirname(tempDir), '.kanban.json')
|
|
2562
2642
|
fs.writeFileSync(kanbanJson, JSON.stringify({
|
|
2563
2643
|
version: 2,
|
|
2564
|
-
boards: { default: { name: 'Default', columns: [], nextCardId: 1, defaultStatus: 'backlog', defaultPriority: 'medium' } },
|
|
2644
|
+
boards: { default: { name: 'Default', columns: [{ id: 'backlog', name: 'Backlog' }], nextCardId: 1, defaultStatus: 'backlog', defaultPriority: 'medium' } },
|
|
2565
2645
|
defaultBoard: 'default',
|
|
2566
2646
|
kanbanDirectory: '.kanban',
|
|
2567
2647
|
forms: {
|
|
@@ -2585,7 +2665,6 @@ describe('Standalone Server Integration', () => {
|
|
|
2585
2665
|
}
|
|
2586
2666
|
}, null, 2), 'utf-8')
|
|
2587
2667
|
|
|
2588
|
-
|
|
2589
2668
|
const createRes = await httpRequest('POST', `http://localhost:${port}/api/tasks`, {
|
|
2590
2669
|
content: '# Interpolation Regression Card',
|
|
2591
2670
|
status: 'backlog',
|
|
@@ -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, {
|
|
@@ -1,11 +1,21 @@
|
|
|
1
|
+
import * as http from 'http'
|
|
1
2
|
import { extractAuthContext, getAuthErrorLike } from '../authUtils'
|
|
3
|
+
import type { AuthContext } from '../../sdk/types'
|
|
2
4
|
import type { StandaloneContext } from '../context'
|
|
3
5
|
import { clearClientEditingCard, setClientEditingCard } from '../broadcastService'
|
|
4
6
|
import { handleMessage } from '../messageHandlers'
|
|
5
7
|
|
|
6
|
-
export function attachWebSocketHandlers(
|
|
7
|
-
ctx
|
|
8
|
-
|
|
8
|
+
export function attachWebSocketHandlers(
|
|
9
|
+
ctx: StandaloneContext,
|
|
10
|
+
resolveAuthContext?: (req: http.IncomingMessage) => Promise<AuthContext>,
|
|
11
|
+
): void {
|
|
12
|
+
ctx.wss.on('connection', async (ws, req) => {
|
|
13
|
+
let authContext: AuthContext
|
|
14
|
+
try {
|
|
15
|
+
authContext = resolveAuthContext ? await resolveAuthContext(req) : extractAuthContext(req)
|
|
16
|
+
} catch {
|
|
17
|
+
authContext = extractAuthContext(req)
|
|
18
|
+
}
|
|
9
19
|
setClientEditingCard(ctx, ws, null)
|
|
10
20
|
ws.on('message', (data) => {
|
|
11
21
|
let message: unknown
|