claude-code-kanban 4.14.1 → 4.17.0

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/server.js CHANGED
@@ -32,7 +32,8 @@ const {
32
32
 
33
33
  if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
34
34
  const { runInstall, runUninstall } = require("./install");
35
- (process.argv.includes("--install") ? runInstall() : runUninstall())
35
+ const pluginOnly = process.argv.includes("--plugin-only");
36
+ (process.argv.includes("--install") ? runInstall({ pluginOnly }) : runUninstall())
36
37
  .then(() => process.exit(0))
37
38
  .catch(e => { console.error(e.message); process.exit(1); });
38
39
  return;
@@ -2612,26 +2613,65 @@ app.delete('/api/tasks/:sessionId/:taskId', async (req, res) => {
2612
2613
  }
2613
2614
  });
2614
2615
 
2615
- // API: Markdown preview — read file and broadcast to clients
2616
- async function readMarkdownFile(absPath) {
2617
- const ext = path.extname(absPath).toLowerCase();
2618
- if (ext !== '.md' && ext !== '.markdown') {
2619
- const err = new Error('Only .md/.markdown files are allowed');
2620
- err.status = 400;
2621
- throw err;
2622
- }
2616
+ // API: File preview — read file and broadcast to clients
2617
+ const PREVIEW_KINDS = { '.md': 'markdown', '.markdown': 'markdown', '.html': 'html', '.htm': 'html' };
2618
+ // Whole files are pushed into a modal, so anything huge freezes the tab regardless of kind.
2619
+ const PREVIEW_MAX_BYTES = 8 * 1024 * 1024;
2620
+
2621
+ function previewError(status, message) {
2622
+ const err = new Error(message);
2623
+ err.status = status;
2624
+ return err;
2625
+ }
2626
+
2627
+ // Existence + kind of a file target, with the HTTP status codes both the preview and
2628
+ // the link-a-file endpoints report. `kind` is null for anything the previewer can't
2629
+ // render — the caller decides whether that disqualifies the path.
2630
+ async function statFileTarget(absPath) {
2623
2631
  try {
2624
- return await fs.readFile(absPath, 'utf8');
2632
+ const stats = await fs.stat(absPath);
2633
+ if (!stats.isFile()) throw previewError(400, 'Not a file');
2634
+ return { size: stats.size, kind: PREVIEW_KINDS[path.extname(absPath).toLowerCase()] || null };
2625
2635
  } catch (e) {
2626
- if (e.code === 'ENOENT') { const err = new Error('File not found'); err.status = 404; throw err; }
2627
- if (e.code === 'EISDIR') { const err = new Error('Not a file'); err.status = 400; throw err; }
2636
+ if (e.status) throw e;
2637
+ if (e.code === 'ENOENT') throw previewError(404, 'File not found');
2638
+ if (e.code === 'EISDIR') throw previewError(400, 'Not a file');
2628
2639
  throw e;
2629
2640
  }
2630
2641
  }
2631
2642
 
2643
+ // Checks the file is previewable without reading it — the broadcast path needs the
2644
+ // validation (and its status codes) but never the content.
2645
+ async function validatePreviewFile(absPath) {
2646
+ const { kind, size } = await statFileTarget(absPath);
2647
+ if (!kind) throw previewError(400, 'Only .md/.markdown/.html/.htm files are allowed');
2648
+ if (size > PREVIEW_MAX_BYTES) {
2649
+ throw previewError(
2650
+ 400,
2651
+ `Preview too large (${Math.round(size / 1048576)}MB, max ${PREVIEW_MAX_BYTES / 1048576}MB)`
2652
+ );
2653
+ }
2654
+ return { kind, size };
2655
+ }
2656
+
2657
+ async function readPreviewFile(absPath) {
2658
+ const { kind } = await validatePreviewFile(absPath);
2659
+ return { content: await fs.readFile(absPath, 'utf8'), kind };
2660
+ }
2661
+
2662
+ // Paths copied out of a browser or a slide deck carry a URL fragment
2663
+ // ("deck.html#10"). Dropped only when the literal path is missing and the trimmed one
2664
+ // exists, so a filename that genuinely contains '#' still resolves to itself.
2665
+ function dropUrlFragment(abs) {
2666
+ const i = abs.lastIndexOf('#');
2667
+ if (i <= 0 || existsSync(abs)) return abs;
2668
+ const trimmed = abs.slice(0, i);
2669
+ return existsSync(trimmed) ? trimmed : abs;
2670
+ }
2671
+
2632
2672
  function resolvePreviewPath(filePath, base) {
2633
2673
  if (!filePath || typeof filePath !== 'string') return null;
2634
- if (path.isAbsolute(filePath)) return filePath;
2674
+ if (path.isAbsolute(filePath)) return dropUrlFragment(filePath);
2635
2675
  if (base && typeof base === 'string' && path.isAbsolute(base)) {
2636
2676
  let baseDir = base;
2637
2677
  try {
@@ -2640,9 +2680,9 @@ function resolvePreviewPath(filePath, base) {
2640
2680
  // base doesn't exist — fall back to dirname if it looks like a file
2641
2681
  if (path.extname(base)) baseDir = path.dirname(base);
2642
2682
  }
2643
- return path.resolve(baseDir, filePath);
2683
+ return dropUrlFragment(path.resolve(baseDir, filePath));
2644
2684
  }
2645
- return path.resolve(filePath);
2685
+ return dropUrlFragment(path.resolve(filePath));
2646
2686
  }
2647
2687
 
2648
2688
  app.post('/api/preview', async (req, res) => {
@@ -2650,8 +2690,10 @@ app.post('/api/preview', async (req, res) => {
2650
2690
  const { path: filePath, sessionId, base } = req.body || {};
2651
2691
  const abs = resolvePreviewPath(filePath, base);
2652
2692
  if (!abs) return res.status(400).json({ error: 'path is required' });
2653
- const content = await readMarkdownFile(abs);
2654
- broadcast({ type: 'preview:open', path: abs, content, sessionId: sessionId || null });
2693
+ // Validate here so the CLI still gets 400/404, but broadcast the path only —
2694
+ // each tab fetches the document itself instead of it being fanned out over SSE.
2695
+ await validatePreviewFile(abs);
2696
+ broadcast({ type: 'preview:open', path: abs, sessionId: sessionId || null });
2655
2697
  res.json({ success: true });
2656
2698
  } catch (error) {
2657
2699
  console.error('Error in /api/preview:', error);
@@ -2659,6 +2701,24 @@ app.post('/api/preview', async (req, res) => {
2659
2701
  }
2660
2702
  });
2661
2703
 
2704
+ // API: Link a file to a session's sidebar docs without opening the preview modal.
2705
+ // Not extension-restricted, matching /api/file/resolve — an unpreviewable link just
2706
+ // opens in the editor. Unlinking skips the stat so a deleted file can still be removed.
2707
+ app.post('/api/document/link', async (req, res) => {
2708
+ try {
2709
+ const { path: filePath, sessionId, unlink } = req.body || {};
2710
+ if (typeof sessionId !== 'string' || !sessionId) return res.status(400).json({ error: 'sessionId is required' });
2711
+ const abs = resolvePreviewPath(filePath);
2712
+ if (!abs) return res.status(400).json({ error: 'path is required' });
2713
+ if (!unlink) await statFileTarget(abs);
2714
+ broadcast({ type: 'document:link', path: abs, sessionId, unlink: !!unlink });
2715
+ res.json({ success: true });
2716
+ } catch (error) {
2717
+ console.error('Error in /api/document/link:', error);
2718
+ res.status(error.status || 500).json({ error: error.message || 'Link failed' });
2719
+ }
2720
+ });
2721
+
2662
2722
  app.get('/api/session/resolve', (req, res) => {
2663
2723
  try {
2664
2724
  const idArg = (req.query.id || '').toString();
@@ -2731,14 +2791,29 @@ app.get('/api/preview', async (req, res) => {
2731
2791
  try {
2732
2792
  const abs = resolvePreviewPath(req.query.path, req.query.base);
2733
2793
  if (!abs) return res.status(400).json({ error: 'path is required' });
2734
- const content = await readMarkdownFile(abs);
2735
- res.json({ path: abs, content });
2794
+ const { content, kind } = await readPreviewFile(abs);
2795
+ res.json({ path: abs, content, kind });
2736
2796
  } catch (error) {
2737
2797
  console.error('Error in GET /api/preview:', error);
2738
2798
  res.status(error.status || 500).json({ error: error.message || 'Preview failed' });
2739
2799
  }
2740
2800
  });
2741
2801
 
2802
+ // API: Resolve a hand-typed path to an absolute one that exists. Deliberately not
2803
+ // extension-restricted — a linked file the previewer can't render (kind: null) is
2804
+ // still linkable, the client just opens it in the editor instead.
2805
+ app.get('/api/file/resolve', async (req, res) => {
2806
+ try {
2807
+ const abs = resolvePreviewPath(req.query.path, req.query.base);
2808
+ if (!abs) return res.status(400).json({ error: 'path is required' });
2809
+ // No size cap here — an unpreviewable file of any size is still linkable.
2810
+ res.json({ path: abs, ...(await statFileTarget(abs)) });
2811
+ } catch (error) {
2812
+ console.error('Error in GET /api/file/resolve:', error);
2813
+ res.status(error.status || 500).json({ error: error.message || 'Failed to resolve file' });
2814
+ }
2815
+ });
2816
+
2742
2817
  // SSE endpoint for live updates
2743
2818
  app.get('/api/events', (req, res) => {
2744
2819
  res.setHeader('Content-Type', 'text/event-stream');