kanban-lite 1.2.3 → 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.
@@ -49920,6 +49920,10 @@ function loadDotEnv(dir2) {
49920
49920
  }
49921
49921
  }
49922
49922
  function resolveConfigEnvVars(node, configFileName, nodePath = "") {
49923
+ const isFormDefaultDataPath = /^\.forms\.(?:[^.]+|"[^"]+")\.data(?:$|[.\[])/.test(nodePath);
49924
+ if (isFormDefaultDataPath) {
49925
+ return node;
49926
+ }
49923
49927
  if (typeof node === "string") {
49924
49928
  return node.replace(/\$\{([^}]+)\}/g, (_match, varName) => {
49925
49929
  const envValue = process.env[varName];
@@ -61678,6 +61682,25 @@ var KanbanSDK = class _KanbanSDK {
61678
61682
  get workspaceRoot() {
61679
61683
  return path17.dirname(this.kanbanDir);
61680
61684
  }
61685
+ /**
61686
+ * Returns a cloned read-only snapshot of the current workspace config.
61687
+ *
61688
+ * The returned snapshot is created from a fresh config read and deep-cloned
61689
+ * before being returned, so callers receive an isolated view of the current
61690
+ * `.kanban.json` state rather than a live mutable runtime object. Mutating the
61691
+ * returned snapshot does not update persisted config or affect this SDK instance.
61692
+ *
61693
+ * @returns A cloned read-only snapshot of the current {@link KanbanConfig}.
61694
+ *
61695
+ * @example
61696
+ * ```ts
61697
+ * const config = sdk.getConfigSnapshot()
61698
+ * console.log(config.defaultBoard)
61699
+ * ```
61700
+ */
61701
+ getConfigSnapshot() {
61702
+ return structuredClone(readConfig(this.workspaceRoot));
61703
+ }
61681
61704
  // --- Board resolution helpers ---
61682
61705
  /** @internal */
61683
61706
  _resolveBoardId(boardId) {
@@ -65218,9 +65241,14 @@ async function handleMessage(ctx, ws, message, authContext) {
65218
65241
  }
65219
65242
 
65220
65243
  // src/standalone/internal/websocket.ts
65221
- function attachWebSocketHandlers(ctx) {
65222
- ctx.wss.on("connection", (ws, req) => {
65223
- const authContext = extractAuthContext(req);
65244
+ function attachWebSocketHandlers(ctx, resolveAuthContext) {
65245
+ ctx.wss.on("connection", async (ws, req) => {
65246
+ let authContext;
65247
+ try {
65248
+ authContext = resolveAuthContext ? await resolveAuthContext(req) : extractAuthContext(req);
65249
+ } catch {
65250
+ authContext = extractAuthContext(req);
65251
+ }
65224
65252
  setClientEditingCard(ctx, ws, null);
65225
65253
  ws.on("message", (data) => {
65226
65254
  let message;
@@ -65387,6 +65415,7 @@ function isPageRequest(method, pathname) {
65387
65415
  function collectStandaloneHttpHandlers(requestType, ctx) {
65388
65416
  const plugins = ctx.sdk.capabilities?.standaloneHttpPlugins ?? [];
65389
65417
  const registrationOptions = {
65418
+ sdk: ctx.sdk,
65390
65419
  workspaceRoot: ctx.workspaceRoot,
65391
65420
  kanbanDir: ctx.absoluteKanbanDir,
65392
65421
  capabilities: ctx.sdk.capabilities?.providers ?? {
@@ -65514,7 +65543,62 @@ function startServer(kanbanDir, port2, webviewDir, resolvedConfigPath) {
65514
65543
  }
65515
65544
  reply.hijack();
65516
65545
  });
65517
- attachWebSocketHandlers(ctx);
65546
+ const resolveWsAuthContext = async (req) => {
65547
+ const silentRes = /* @__PURE__ */ (() => {
65548
+ const r = {
65549
+ writableEnded: false,
65550
+ writeHead() {
65551
+ return r;
65552
+ },
65553
+ setHeader() {
65554
+ return r;
65555
+ },
65556
+ removeHeader() {
65557
+ },
65558
+ getHeader() {
65559
+ return void 0;
65560
+ },
65561
+ getHeaders() {
65562
+ return {};
65563
+ },
65564
+ end(..._args) {
65565
+ r.writableEnded = true;
65566
+ return r;
65567
+ },
65568
+ write() {
65569
+ return false;
65570
+ }
65571
+ };
65572
+ return r;
65573
+ })();
65574
+ const reqWithBody = req;
65575
+ const wsUrl = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
65576
+ const requestContext = {
65577
+ ctx,
65578
+ sdk: ctx.sdk,
65579
+ workspaceRoot: ctx.workspaceRoot,
65580
+ kanbanDir: ctx.absoluteKanbanDir,
65581
+ req: reqWithBody,
65582
+ res: silentRes,
65583
+ url: wsUrl,
65584
+ pathname: wsUrl.pathname,
65585
+ method: "GET",
65586
+ resolvedWebviewDir,
65587
+ indexHtml: resolvedIndexHtml,
65588
+ route: createRouteMatcher("GET", wsUrl.pathname, matchRoute),
65589
+ isApiRequest: false,
65590
+ isPageRequest: false,
65591
+ getAuthContext: () => getRequestAuthContext(req),
65592
+ setAuthContext: (auth) => setRequestAuthContext(req, auth),
65593
+ mergeAuthContext: (auth) => mergeRequestAuthContext(req, auth)
65594
+ };
65595
+ for (const handler of middlewareHandlers) {
65596
+ if (await handler(requestContext))
65597
+ break;
65598
+ }
65599
+ return extractAuthContext(req);
65600
+ };
65601
+ attachWebSocketHandlers(ctx, resolveWsAuthContext);
65518
65602
  setupStandaloneLifecycle(ctx, fastify.server);
65519
65603
  const effectiveConfigPath = resolvedConfigPath ?? configPath(path20.dirname(ctx.absoluteKanbanDir));
65520
65604
  fastify.listen({ port: port2, host: "0.0.0.0" }, (err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kanban-lite",
3
- "version": "1.2.3",
3
+ "version": "1.2.4",
4
4
  "description": "Kanban board manager - VSCode extension, CLI, MCP server, and standalone web app",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -53,6 +53,85 @@ function createCliAuthWorkspace(): { workspaceDir: string; configPath: string; c
53
53
  }
54
54
  }
55
55
 
56
+ function createCliWorkspace(config: Record<string, unknown>): { workspaceDir: string; configPath: string; cleanup: () => void } {
57
+ const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kanban-cli-plugin-'))
58
+ fs.mkdirSync(path.join(workspaceDir, '.kanban'), { recursive: true })
59
+ const configPath = path.join(workspaceDir, '.kanban.json')
60
+ fs.writeFileSync(
61
+ configPath,
62
+ JSON.stringify({
63
+ version: 2,
64
+ defaultBoard: 'default',
65
+ kanbanDirectory: '.kanban',
66
+ boards: {
67
+ default: {
68
+ name: 'Default',
69
+ columns: [{ id: 'backlog', name: 'Backlog' }],
70
+ nextCardId: 1,
71
+ defaultStatus: 'backlog',
72
+ defaultPriority: 'medium',
73
+ },
74
+ },
75
+ ...config,
76
+ }, null, 2) + '\n',
77
+ 'utf-8',
78
+ )
79
+ return {
80
+ workspaceDir,
81
+ configPath,
82
+ cleanup: () => fs.rmSync(workspaceDir, { recursive: true, force: true }),
83
+ }
84
+ }
85
+
86
+ function installTempCliPlugin(packageName: string, entrySource: string): () => void {
87
+ const packageDir = path.join(WORKSPACE_ROOT, 'node_modules', packageName)
88
+ fs.mkdirSync(packageDir, { recursive: true })
89
+ fs.writeFileSync(
90
+ path.join(packageDir, 'package.json'),
91
+ JSON.stringify({ name: packageName, main: 'index.js' }, null, 2),
92
+ 'utf-8',
93
+ )
94
+ fs.writeFileSync(path.join(packageDir, 'index.js'), entrySource, 'utf-8')
95
+ return () => fs.rmSync(packageDir, { recursive: true, force: true })
96
+ }
97
+
98
+ function createCliSdkProbePackageSource(packageName: string, command: string): string {
99
+ return [
100
+ "const fs = require('node:fs')",
101
+ `const packageName = ${JSON.stringify(packageName)}`,
102
+ `const command = ${JSON.stringify(command)}`,
103
+ 'module.exports.authIdentityPlugin = {',
104
+ ' manifest: { id: packageName, provides: [\'auth.identity\'] },',
105
+ ' async resolveIdentity() { return null },',
106
+ '}',
107
+ 'module.exports.cliPlugin = {',
108
+ ' manifest: { id: packageName },',
109
+ ' command,',
110
+ ' async run(subArgs, flags, context) {',
111
+ ' const runWithCliAuthResult = typeof context.runWithCliAuth === \"function\"',
112
+ ' ? await context.runWithCliAuth(() => Promise.resolve(\"ok\"))',
113
+ ' : null',
114
+ ' const payload = {',
115
+ ' subArgs,',
116
+ ' hasSdk: !!context.sdk,',
117
+ ' hasGetConfigSnapshot: typeof context.sdk?.getConfigSnapshot === \"function\",',
118
+ ' hasGetBoard: typeof context.sdk?.getBoard === "function",',
119
+ ' hasGetExtension: typeof context.sdk?.getExtension === \"function\",',
120
+ ' hasWorkspaceRootGetter: typeof context.sdk?.workspaceRoot === \"string\",',
121
+ ' snapshotDefaultBoard: context.sdk?.getConfigSnapshot?.().defaultBoard ?? null,',
122
+ ' defaultBoardName: context.sdk?.getBoard?.("default")?.name ?? null,',
123
+ ' runWithCliAuthResult,',
124
+ ' workspaceRoot: context.workspaceRoot,',
125
+ ' flagCount: Object.keys(flags || {}).length,',
126
+ ' }',
127
+ ' if (process.env.KANBAN_SDK_PROBE_OUTPUT) {',
128
+ ' fs.writeFileSync(process.env.KANBAN_SDK_PROBE_OUTPUT, JSON.stringify(payload, null, 2))',
129
+ ' }',
130
+ ' },',
131
+ '}',
132
+ ].join('\n')
133
+ }
134
+
56
135
  async function createCliCardStateWorkspace(): Promise<{ workspaceDir: string; configPath: string; cardId: string; cleanup: () => void }> {
57
136
  const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kanban-cli-card-state-'))
58
137
  fs.mkdirSync(path.join(workspaceDir, '.kanban'), { recursive: true })
@@ -733,3 +812,81 @@ describe('Webhook CLI routing — plugin-owned dispatch', () => {
733
812
  })
734
813
  })
735
814
 
815
+ describe('CLI plugin context SDK injection regressions', () => {
816
+ it('passes the full public SDK to standard CLI plugin commands', async () => {
817
+ const packageName = `kanban-cli-sdk-probe-${Date.now()}-standard`
818
+ const cleanupPlugin = installTempCliPlugin(
819
+ packageName,
820
+ createCliSdkProbePackageSource(packageName, 'sdk-probe'),
821
+ )
822
+ const { workspaceDir, configPath, cleanup } = createCliWorkspace({
823
+ auth: {
824
+ 'auth.identity': { provider: packageName },
825
+ },
826
+ })
827
+ const markerPath = path.join(workspaceDir, 'sdk-probe-standard.json')
828
+
829
+ try {
830
+ const result = await runCliCommand(['sdk-probe', '--config', configPath], {
831
+ KANBAN_SDK_PROBE_OUTPUT: markerPath,
832
+ })
833
+
834
+ expect(result.exitCode).toBe(0)
835
+ const payload = JSON.parse(fs.readFileSync(markerPath, 'utf-8')) as Record<string, unknown>
836
+ expect(payload).toMatchObject({
837
+ subArgs: [],
838
+ hasSdk: true,
839
+ hasGetConfigSnapshot: true,
840
+ hasGetBoard: true,
841
+ hasGetExtension: true,
842
+ hasWorkspaceRootGetter: true,
843
+ snapshotDefaultBoard: 'default',
844
+ defaultBoardName: 'Default',
845
+ runWithCliAuthResult: 'ok',
846
+ workspaceRoot: workspaceDir,
847
+ })
848
+ } finally {
849
+ cleanup()
850
+ cleanupPlugin()
851
+ }
852
+ })
853
+
854
+ it('passes the full public SDK through the auth special-case CLI path', async () => {
855
+ const packageName = `kanban-cli-sdk-probe-${Date.now()}-auth`
856
+ const cleanupPlugin = installTempCliPlugin(
857
+ packageName,
858
+ createCliSdkProbePackageSource(packageName, 'auth'),
859
+ )
860
+ const { workspaceDir, configPath, cleanup } = createCliWorkspace({
861
+ auth: {
862
+ 'auth.identity': { provider: packageName },
863
+ },
864
+ })
865
+ const markerPath = path.join(workspaceDir, 'sdk-probe-auth.json')
866
+
867
+ try {
868
+ const result = await runCliCommand(['auth', 'inspect', '--config', configPath], {
869
+ KANBAN_SDK_PROBE_OUTPUT: markerPath,
870
+ })
871
+
872
+ expect(result.exitCode).toBe(0)
873
+ const payload = JSON.parse(fs.readFileSync(markerPath, 'utf-8')) as Record<string, unknown>
874
+ expect(payload).toMatchObject({
875
+ subArgs: ['inspect'],
876
+ hasSdk: true,
877
+ hasGetConfigSnapshot: true,
878
+ hasGetBoard: true,
879
+ hasGetExtension: true,
880
+ hasWorkspaceRootGetter: true,
881
+ snapshotDefaultBoard: 'default',
882
+ defaultBoardName: 'Default',
883
+ runWithCliAuthResult: 'ok',
884
+ workspaceRoot: workspaceDir,
885
+ })
886
+ } finally {
887
+ cleanup()
888
+ cleanupPlugin()
889
+ }
890
+ })
891
+ })
892
+
package/src/cli/index.ts CHANGED
@@ -1869,7 +1869,7 @@ async function cmdAuth(sdk: KanbanSDK, positional: string[], flags: Flags, cliPl
1869
1869
  if (sub !== 'status') {
1870
1870
  const authPlugin = findCliPlugin(cliPlugins, 'auth')
1871
1871
  if (authPlugin) {
1872
- await authPlugin.run(positional, flags, { workspaceRoot })
1872
+ await runCliPlugin(authPlugin, positional, flags, workspaceRoot, sdk)
1873
1873
  return
1874
1874
  }
1875
1875
  console.error(red(`Unknown auth sub-command: ${sub}`))
@@ -581,6 +581,82 @@ describe('plugin-owned MCP webhook registration', () => {
581
581
  }
582
582
  })
583
583
 
584
+ it('keeps widened MCP plugin contexts compatible with full public SDK reads', async () => {
585
+ const tools: Array<{
586
+ name: string
587
+ description: string
588
+ schema: Record<string, unknown>
589
+ handler: (args: Record<string, unknown>) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>
590
+ }> = []
591
+
592
+ const server = {
593
+ tool(
594
+ name: string,
595
+ description: string,
596
+ schema: Record<string, unknown>,
597
+ handler: (args: Record<string, unknown>) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>,
598
+ ) {
599
+ tools.push({ name, description, schema, handler })
600
+ },
601
+ }
602
+
603
+ const probePlugin = {
604
+ manifest: { id: 'sdk-seam-probe', provides: ['mcp.tools'] as const },
605
+ registerTools: (ctx: ReturnType<typeof createMcpPluginContext>) => [{
606
+ name: 'sdk_snapshot_probe',
607
+ description: 'Regression probe for widened MCP SDK context.',
608
+ inputSchema: () => ({}),
609
+ handler: async () => ({
610
+ content: [{
611
+ type: 'text' as const,
612
+ text: JSON.stringify({
613
+ hasGetConfigSnapshot: typeof ctx.sdk.getConfigSnapshot === 'function',
614
+ hasGetBoard: typeof ctx.sdk.getBoard === 'function',
615
+ hasGetExtension: typeof ctx.sdk.getExtension === 'function',
616
+ defaultBoard: ctx.sdk.getConfigSnapshot().defaultBoard,
617
+ defaultBoardName: ctx.sdk.getBoard('default').name,
618
+ workspaceRoot: ctx.sdk.workspaceRoot,
619
+ }),
620
+ }],
621
+ }),
622
+ }],
623
+ }
624
+
625
+ const resolveSpy = vi.spyOn(pluginRegistry, 'resolveMcpPlugins').mockReturnValue([
626
+ mcpPlugin as never,
627
+ probePlugin as never,
628
+ ])
629
+
630
+ try {
631
+ const registered = registerPluginMcpTools(
632
+ server,
633
+ {},
634
+ createMcpPluginContext({
635
+ sdk,
636
+ workspaceRoot: workspaceDir,
637
+ kanbanDir,
638
+ runWithAuth: (fn) => sdk.runWithAuth({ transport: 'mcp' }, fn),
639
+ }),
640
+ )
641
+
642
+ expect(registered).toContain('sdk_snapshot_probe')
643
+ const probeTool = tools.find((tool) => tool.name === 'sdk_snapshot_probe')
644
+ expect(probeTool).toBeDefined()
645
+
646
+ const result = await probeTool!.handler({})
647
+ expect(JSON.parse(result.content[0].text)).toEqual({
648
+ hasGetConfigSnapshot: true,
649
+ hasGetBoard: true,
650
+ hasGetExtension: true,
651
+ defaultBoard: 'default',
652
+ defaultBoardName: 'Default',
653
+ workspaceRoot: workspaceDir,
654
+ })
655
+ } finally {
656
+ resolveSpy.mockRestore()
657
+ }
658
+ })
659
+
584
660
  it('rejects duplicate plugin-owned MCP tool registration for webhook tools', () => {
585
661
  const duplicatePlugin = {
586
662
  manifest: { id: 'duplicate-webhooks-test', provides: ['mcp.tools'] as const },
@@ -1,6 +1,6 @@
1
1
  import type { Comment, Card, KanbanColumn, BoardInfo, LabelDefinition, CardSortOption, LogEntry } from '../shared/types';
2
2
  import type { CardDisplaySettings, Priority } from '../shared/types';
3
- import type { BoardConfig, ResolvedCapabilities, Webhook } from '../shared/config';
3
+ import type { BoardConfig, KanbanConfig, ResolvedCapabilities, Webhook } from '../shared/config';
4
4
  import type { CreateCardInput, SDKEvent, SDKEventType, SDKOptions, SubmitFormInput, SubmitFormResult, AuthContext, AuthDecision, SDKBeforeEventType, SDKAfterEventType, CardStateStatus, CardUnreadSummary } from './types';
5
5
  import type { EventBusAnyListener, EventBusWaitOptions } from './eventBus';
6
6
  import { EventBus } from './eventBus';
@@ -67,6 +67,9 @@ export interface WebhookStatus {
67
67
  }
68
68
  /** Active card-state provider metadata for diagnostics and host surfaces. */
69
69
  export type CardStateRuntimeStatus = CardStateStatus;
70
+ type ReadonlySnapshot<T> = T extends (...args: never[]) => unknown ? T : T extends readonly (infer U)[] ? readonly ReadonlySnapshot<U>[] : T extends object ? {
71
+ readonly [K in keyof T]: ReadonlySnapshot<T[K]>;
72
+ } : T;
70
73
  /**
71
74
  * Optional search and sort inputs for {@link KanbanSDK.listCards}.
72
75
  *
@@ -524,6 +527,23 @@ export declare class KanbanSDK {
524
527
  * ```
525
528
  */
526
529
  get workspaceRoot(): string;
530
+ /**
531
+ * Returns a cloned read-only snapshot of the current workspace config.
532
+ *
533
+ * The returned snapshot is created from a fresh config read and deep-cloned
534
+ * before being returned, so callers receive an isolated view of the current
535
+ * `.kanban.json` state rather than a live mutable runtime object. Mutating the
536
+ * returned snapshot does not update persisted config or affect this SDK instance.
537
+ *
538
+ * @returns A cloned read-only snapshot of the current {@link KanbanConfig}.
539
+ *
540
+ * @example
541
+ * ```ts
542
+ * const config = sdk.getConfigSnapshot()
543
+ * console.log(config.defaultBoard)
544
+ * ```
545
+ */
546
+ getConfigSnapshot(): ReadonlySnapshot<KanbanConfig>;
527
547
  /** @internal */
528
548
  _resolveBoardId(boardId?: string): string;
529
549
  /** @internal */
@@ -4,7 +4,7 @@ import type { Comment, Card, KanbanColumn, BoardInfo, LabelDefinition, CardSortO
4
4
  import type { CardDisplaySettings, Priority } from '../shared/types'
5
5
  import { DELETED_STATUS_ID } from '../shared/types'
6
6
  import { readConfig, normalizeStorageCapabilities, normalizeAuthCapabilities, normalizeWebhookCapabilities, normalizeCardStateCapabilities } from '../shared/config'
7
- import type { BoardConfig, ProviderRef, ResolvedCapabilities, ResolvedWebhookCapabilities, ResolvedCardStateCapabilities, Webhook } from '../shared/config'
7
+ import type { BoardConfig, KanbanConfig, ProviderRef, ResolvedCapabilities, ResolvedWebhookCapabilities, ResolvedCardStateCapabilities, Webhook } from '../shared/config'
8
8
  import type { ResolvedAuthCapabilities } from '../shared/config'
9
9
  import type { CreateCardInput, SDKEvent, SDKEventHandler, SDKEventType, SDKOptions, SubmitFormInput, SubmitFormResult, AuthContext, AuthDecision, SDKEventListenerPlugin, BeforeEventPayload, AfterEventPayload, SDKBeforeEventType, SDKAfterEventType, CardStateStatus, CardOpenStateValue, CardUnreadSummary } from './types'
10
10
  import type { EventBusAnyListener, EventBusWaitOptions } from './eventBus'
@@ -113,6 +113,15 @@ type MethodInput<TMethod> =
113
113
  ? TFirst
114
114
  : Record<string, unknown>
115
115
 
116
+ type ReadonlySnapshot<T> =
117
+ T extends (...args: never[]) => unknown
118
+ ? T
119
+ : T extends readonly (infer U)[]
120
+ ? readonly ReadonlySnapshot<U>[]
121
+ : T extends object
122
+ ? { readonly [K in keyof T]: ReadonlySnapshot<T[K]> }
123
+ : T
124
+
116
125
  /**
117
126
  * Active webhook provider metadata for diagnostics and host surfaces.
118
127
  *
@@ -1158,6 +1167,26 @@ export class KanbanSDK {
1158
1167
  return path.dirname(this.kanbanDir)
1159
1168
  }
1160
1169
 
1170
+ /**
1171
+ * Returns a cloned read-only snapshot of the current workspace config.
1172
+ *
1173
+ * The returned snapshot is created from a fresh config read and deep-cloned
1174
+ * before being returned, so callers receive an isolated view of the current
1175
+ * `.kanban.json` state rather than a live mutable runtime object. Mutating the
1176
+ * returned snapshot does not update persisted config or affect this SDK instance.
1177
+ *
1178
+ * @returns A cloned read-only snapshot of the current {@link KanbanConfig}.
1179
+ *
1180
+ * @example
1181
+ * ```ts
1182
+ * const config = sdk.getConfigSnapshot()
1183
+ * console.log(config.defaultBoard)
1184
+ * ```
1185
+ */
1186
+ getConfigSnapshot(): ReadonlySnapshot<KanbanConfig> {
1187
+ return structuredClone(readConfig(this.workspaceRoot)) as ReadonlySnapshot<KanbanConfig>
1188
+ }
1189
+
1161
1190
  // --- Board resolution helpers ---
1162
1191
 
1163
1192
  /** @internal */
@@ -155,6 +155,62 @@ describe('KanbanSDK', () => {
155
155
  })
156
156
  })
157
157
 
158
+ describe('getConfigSnapshot', () => {
159
+ it('returns an isolated clone that cannot mutate subsequent SDK reads or persisted config', () => {
160
+ writeWorkspaceConfig(workspaceDir, {
161
+ defaultBoard: 'default',
162
+ kanbanDirectory: '.kanban',
163
+ boards: {
164
+ default: {
165
+ name: 'Default',
166
+ columns: [{ id: 'backlog', name: 'Backlog' }],
167
+ nextCardId: 1,
168
+ defaultStatus: 'backlog',
169
+ defaultPriority: 'medium',
170
+ },
171
+ },
172
+ plugins: {
173
+ 'auth.identity': {
174
+ provider: 'kl-auth-plugin',
175
+ options: {
176
+ users: [{ username: 'alice', password: '$2b$12$existing-hash' }],
177
+ },
178
+ },
179
+ },
180
+ webhooks: [
181
+ { id: 'wh_snapshot', url: 'https://example.com/original', events: ['*'], active: true },
182
+ ],
183
+ })
184
+
185
+ const firstSnapshot = sdk.getConfigSnapshot() as any
186
+ firstSnapshot.defaultBoard = 'mutated-board'
187
+ firstSnapshot.boards.default.name = 'Mutated Board'
188
+ firstSnapshot.webhooks[0].url = 'https://example.com/mutated'
189
+ firstSnapshot.plugins['auth.identity'].options.users.push({ username: 'mallory', password: 'bad-hash' })
190
+
191
+ const secondSnapshot = sdk.getConfigSnapshot() as any
192
+ const persisted = JSON.parse(fs.readFileSync(path.join(workspaceDir, '.kanban.json'), 'utf-8')) as any
193
+
194
+ expect(secondSnapshot.defaultBoard).toBe('default')
195
+ expect(secondSnapshot.boards.default.name).toBe('Default')
196
+ expect(secondSnapshot.webhooks[0].url).toBe('https://example.com/original')
197
+ expect(secondSnapshot.plugins['auth.identity'].options.users).toEqual([
198
+ { username: 'alice', password: '$2b$12$existing-hash' },
199
+ ])
200
+ expect(sdk.listWebhooks()).toEqual([
201
+ { id: 'wh_snapshot', url: 'https://example.com/original', events: ['*'], active: true },
202
+ ])
203
+ expect(persisted.defaultBoard).toBe('default')
204
+ expect(persisted.boards.default.name).toBe('Default')
205
+ expect(persisted.webhooks).toEqual([
206
+ { id: 'wh_snapshot', url: 'https://example.com/original', events: ['*'], active: true },
207
+ ])
208
+ expect(persisted.plugins['auth.identity'].options.users).toEqual([
209
+ { username: 'alice', password: '$2b$12$existing-hash' },
210
+ ])
211
+ })
212
+ })
213
+
158
214
  describe('getCardStateStatus', () => {
159
215
  it('reports builtin backend status and allows the default actor when auth.identity is noop', () => {
160
216
  const status = sdk.getCardStateStatus()
@@ -1080,10 +1136,18 @@ describe('KanbanSDK', () => {
1080
1136
  expect(reloaded?.actions).toBeUndefined()
1081
1137
  })
1082
1138
 
1083
- it('should throw if no actionWebhookUrl is configured', async () => {
1139
+ it('should append an activity log entry even when no actionWebhookUrl is configured', async () => {
1084
1140
  await sdk.init()
1085
1141
  const card = await sdk.createCard({ content: '# Card', actions: ['retry'] })
1086
- await expect(sdk.triggerAction(card.id, 'retry')).rejects.toThrow('No action webhook URL configured')
1142
+ await expect(sdk.triggerAction(card.id, 'retry')).resolves.toBeUndefined()
1143
+
1144
+ const logs = await sdk.listLogs(card.id)
1145
+ expect(logs).toHaveLength(1)
1146
+ expect(logs[0]).toMatchObject({
1147
+ source: 'system',
1148
+ text: 'Action triggered: `retry`',
1149
+ object: { action: 'retry' },
1150
+ })
1087
1151
  })
1088
1152
 
1089
1153
  it('should throw if card not found', async () => {
@@ -1094,45 +1158,36 @@ describe('KanbanSDK', () => {
1094
1158
  await expect(sdk.triggerAction('nonexistent', 'retry')).rejects.toThrow('Card not found')
1095
1159
  })
1096
1160
 
1097
- it('should POST correct payload to actionWebhookUrl on success', async () => {
1161
+ it('should not call fetch directly; webhook delivery is delegated to plugin after-events', async () => {
1098
1162
  await sdk.init()
1099
1163
  const card = await sdk.createCard({ content: '# My Card', actions: ['retry'] })
1100
1164
 
1101
- const { readConfig, writeConfig } = await import('../../shared/config')
1102
- const config = readConfig(sdk.workspaceRoot)
1103
- writeConfig(sdk.workspaceRoot, { ...config, actionWebhookUrl: 'https://example.com/webhook' })
1104
-
1105
1165
  const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: 'OK' })
1106
1166
  vi.stubGlobal('fetch', mockFetch)
1107
1167
 
1108
1168
  await sdk.triggerAction(card.id, 'retry')
1109
1169
 
1110
- expect(mockFetch).toHaveBeenCalledOnce()
1111
- const [url, init] = mockFetch.mock.calls[0]
1112
- expect(url).toBe('https://example.com/webhook')
1113
- expect(init.method).toBe('POST')
1114
- expect(init.headers).toEqual({ 'Content-Type': 'application/json' })
1115
- const body = JSON.parse(init.body)
1116
- expect(body.action).toBe('retry')
1117
- expect(body.board).toBe('default')
1118
- expect(body.list).toBe(card.status)
1119
- expect(body.card.id).toBe(card.id)
1120
- expect(body.card.filePath).toBeUndefined()
1170
+ expect(mockFetch).not.toHaveBeenCalled()
1171
+
1172
+ const logs = await sdk.listLogs(card.id)
1173
+ expect(logs.at(-1)).toMatchObject({
1174
+ text: 'Action triggered: `retry`',
1175
+ object: { action: 'retry' },
1176
+ })
1121
1177
 
1122
1178
  vi.unstubAllGlobals()
1123
1179
  })
1124
1180
 
1125
- it('should throw on non-2xx webhook response', async () => {
1181
+ it('should stay side-effect free with respect to direct webhook transport failures', async () => {
1126
1182
  await sdk.init()
1127
1183
  const card = await sdk.createCard({ content: '# My Card', actions: ['retry'] })
1128
1184
 
1129
- const { readConfig, writeConfig } = await import('../../shared/config')
1130
- const config = readConfig(sdk.workspaceRoot)
1131
- writeConfig(sdk.workspaceRoot, { ...config, actionWebhookUrl: 'https://example.com/webhook' })
1132
-
1133
1185
  vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500, statusText: 'Internal Server Error' }))
1134
1186
 
1135
- await expect(sdk.triggerAction(card.id, 'retry')).rejects.toThrow('Action webhook responded with 500')
1187
+ await expect(sdk.triggerAction(card.id, 'retry')).resolves.toBeUndefined()
1188
+
1189
+ const logs = await sdk.listLogs(card.id)
1190
+ expect(logs.at(-1)?.text).toBe('Action triggered: `retry`')
1136
1191
 
1137
1192
  vi.unstubAllGlobals()
1138
1193
  })
@@ -337,6 +337,13 @@ export type StandaloneHttpHandler = (request: StandaloneHttpRequestContext) => P
337
337
  * resolved the active workspace capability selections.
338
338
  */
339
339
  export interface StandaloneHttpPluginRegistrationOptions {
340
+ /**
341
+ * Active SDK instance backing the standalone runtime, when provided by the host.
342
+ *
343
+ * Plugin registration code may use the full public {@link KanbanSDK} surface,
344
+ * including `getConfigSnapshot()`, when this seam is available.
345
+ */
346
+ readonly sdk?: KanbanSDK;
340
347
  /** Absolute workspace root containing `.kanban.json`. */
341
348
  readonly workspaceRoot: string;
342
349
  /** Absolute workspace `.kanban` directory. */
@@ -623,6 +623,13 @@ export type StandaloneHttpHandler = (request: StandaloneHttpRequestContext) => P
623
623
  * resolved the active workspace capability selections.
624
624
  */
625
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
626
633
  /** Absolute workspace root containing `.kanban.json`. */
627
634
  readonly workspaceRoot: string
628
635
  /** Absolute workspace `.kanban` directory. */