@tenonhq/dovetail-dashboard 0.0.13 → 0.0.15

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
@@ -23,6 +23,11 @@ const recentEditsLimiter = RateLimit({
23
23
  windowMs: 15 * 60 * 1000,
24
24
  max: 100,
25
25
  });
26
+ // Rate limiter for claude-plans destructive operations
27
+ const claudePlansLimiter = RateLimit({
28
+ windowMs: 15 * 60 * 1000,
29
+ max: 60,
30
+ });
26
31
  const SN_PASSWORD = process.env.SN_PASSWORD || "";
27
32
  const BASE_URL = `https://${SN_INSTANCE}`;
28
33
 
@@ -279,6 +284,7 @@ app.get("/api/recent-edits", recentEditsLimiter, async function (req, res) {
279
284
  }
280
285
 
281
286
  enriched.push({
287
+ sys_id: edit.sys_id,
282
288
  tableName: edit.tableName,
283
289
  name: edit.name,
284
290
  scope: edit.scope,
@@ -293,6 +299,28 @@ app.get("/api/recent-edits", recentEditsLimiter, async function (req, res) {
293
299
  }
294
300
  });
295
301
 
302
+ // POST /api/recent-edits/dismiss — remove one entry from the local recent edits file
303
+ app.post("/api/recent-edits/dismiss", function (req, res) {
304
+ try {
305
+ var sys_id = req.body.sys_id;
306
+ var tableName = req.body.tableName;
307
+ if (!sys_id || !tableName) {
308
+ return res.status(400).json({ error: "sys_id and tableName required" });
309
+ }
310
+ var edits = [];
311
+ if (fs.existsSync(RECENT_EDITS_FILE)) {
312
+ edits = JSON.parse(fs.readFileSync(RECENT_EDITS_FILE, "utf8"));
313
+ }
314
+ var filtered = edits.filter(function (e) {
315
+ return !(e.sys_id === sys_id && e.tableName === tableName);
316
+ });
317
+ fs.writeFileSync(RECENT_EDITS_FILE, JSON.stringify(filtered, null, 2));
318
+ res.json({ ok: true });
319
+ } catch (e) {
320
+ res.status(500).json({ error: e.message });
321
+ }
322
+ });
323
+
296
324
  // GET /api/update-sets/:scope — list in-progress update sets for a scope
297
325
  app.get("/api/update-sets/:scope", async (req, res) => {
298
326
  try {
@@ -772,14 +800,244 @@ app.post("/api/clickup/deselect-task", function (req, res) {
772
800
  }
773
801
  });
774
802
 
803
+ // --- Claude Plans Panel ---
804
+ // Reads JSON records written by @tenonhq/dovetail-claude-plans into
805
+ // ~/.dovetail/claude-plans/ and streams updates to the /claude-plans page.
806
+ // Storage layout:
807
+ // <root>/<plan-slug>.json
808
+ // <root>/<plan-slug>/artifacts/<artifact-slug>.json
809
+ const os = require("os");
810
+ const chokidar = require("chokidar");
811
+
812
+ const CLAUDE_PLANS_DIR =
813
+ process.env.DOVE_CLAUDE_PLANS_DIR ||
814
+ path.join(os.homedir(), ".dovetail", "claude-plans");
815
+
816
+ // Slugs are written by @tenonhq/dovetail-claude-plans' slugify() — kebab-case,
817
+ // max 64 chars. We re-validate on the read side so a request like `..%2Ffoo`
818
+ // cannot escape CLAUDE_PLANS_DIR via path.join.
819
+ const CLAUDE_PLAN_SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
820
+
821
+ function isValidSlug(slug) {
822
+ return typeof slug === "string" && CLAUDE_PLAN_SLUG.test(slug);
823
+ }
824
+
825
+ function planFilePath(slug) {
826
+ return path.join(CLAUDE_PLANS_DIR, slug + ".json");
827
+ }
828
+
829
+ function artifactsDirFor(slug) {
830
+ return path.join(CLAUDE_PLANS_DIR, slug, "artifacts");
831
+ }
832
+
833
+ function safeReadJson(filePath) {
834
+ try {
835
+ if (!fs.existsSync(filePath)) return null;
836
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
837
+ } catch (e) {
838
+ return null;
839
+ }
840
+ }
841
+
842
+ function listClaudePlans() {
843
+ if (!fs.existsSync(CLAUDE_PLANS_DIR)) return [];
844
+ const entries = fs.readdirSync(CLAUDE_PLANS_DIR);
845
+ const plans = [];
846
+ for (let i = 0; i < entries.length; i++) {
847
+ const name = entries[i];
848
+ if (!name.endsWith(".json")) continue;
849
+ const plan = safeReadJson(path.join(CLAUDE_PLANS_DIR, name));
850
+ if (plan) plans.push(plan);
851
+ }
852
+ plans.sort(function (a, b) {
853
+ return (b.updated_at || "").localeCompare(a.updated_at || "");
854
+ });
855
+ return plans;
856
+ }
857
+
858
+ function listClaudeArtifacts(slug) {
859
+ if (!isValidSlug(slug)) return [];
860
+ const dir = artifactsDirFor(slug);
861
+ if (!fs.existsSync(dir)) return [];
862
+ const entries = fs.readdirSync(dir);
863
+ const artifacts = [];
864
+ for (let i = 0; i < entries.length; i++) {
865
+ if (!entries[i].endsWith(".json")) continue;
866
+ const a = safeReadJson(path.join(dir, entries[i]));
867
+ if (a) artifacts.push(a);
868
+ }
869
+ artifacts.sort(function (a, b) {
870
+ return (a.created_at || "").localeCompare(b.created_at || "");
871
+ });
872
+ return artifacts;
873
+ }
874
+
875
+ // Parse a watcher path like "<root>/<slug>.json" or
876
+ // "<root>/<slug>/artifacts/<artifact-slug>.json" into { kind, slug, artifactSlug }.
877
+ function classifyPath(filePath) {
878
+ const rel = path.relative(CLAUDE_PLANS_DIR, filePath);
879
+ if (!rel || rel.startsWith("..")) return null;
880
+ const parts = rel.split(path.sep);
881
+ if (parts.length === 1 && parts[0] === ".focus") {
882
+ return { kind: "focus" };
883
+ }
884
+ if (parts.length === 1 && parts[0].endsWith(".json")) {
885
+ return { kind: "plan", slug: parts[0].slice(0, -5) };
886
+ }
887
+ if (parts.length === 3 && parts[1] === "artifacts" && parts[2].endsWith(".json")) {
888
+ return { kind: "artifact", slug: parts[0], artifactSlug: parts[2].slice(0, -5) };
889
+ }
890
+ return null;
891
+ }
892
+
893
+ // SSE fan-out. Each connected client is a response object held open until the
894
+ // client disconnects; broadcastClaudePlanEvent writes a single SSE frame to all.
895
+ const claudePlanSseClients = new Set();
896
+
897
+ function broadcastClaudePlanEvent(event, data) {
898
+ const frame = "event: " + event + "\ndata: " + JSON.stringify(data) + "\n\n";
899
+ for (const res of claudePlanSseClients) {
900
+ try {
901
+ res.write(frame);
902
+ } catch (err) {
903
+ claudePlanSseClients.delete(res);
904
+ }
905
+ }
906
+ }
907
+
908
+ function handleWatcherChange(event, filePath) {
909
+ const info = classifyPath(filePath);
910
+ if (!info) return;
911
+ if (info.kind === "focus") {
912
+ const focus = safeReadJson(filePath);
913
+ if (focus && isValidSlug(focus.slug)) {
914
+ broadcastClaudePlanEvent("plan:focus", { slug: focus.slug });
915
+ }
916
+ return;
917
+ }
918
+ if (info.kind === "plan") {
919
+ if (event === "unlink") {
920
+ broadcastClaudePlanEvent("plan:delete", { slug: info.slug });
921
+ return;
922
+ }
923
+ const plan = safeReadJson(filePath);
924
+ if (plan) broadcastClaudePlanEvent("plan:upsert", { plan: plan });
925
+ return;
926
+ }
927
+ if (info.kind === "artifact") {
928
+ if (event === "unlink") {
929
+ broadcastClaudePlanEvent("artifact:delete", {
930
+ plan_slug: info.slug,
931
+ slug: info.artifactSlug
932
+ });
933
+ return;
934
+ }
935
+ const artifact = safeReadJson(filePath);
936
+ if (artifact) broadcastClaudePlanEvent("artifact:upsert", { artifact: artifact });
937
+ }
938
+ }
939
+
940
+ // Start the watcher lazily — create the storage dir if missing so chokidar has
941
+ // something to watch. ignoreInitial avoids replaying every file on boot
942
+ // (clients fetch initial state via GET /api/claude-plans).
943
+ let claudePlanWatcher = null;
944
+ function startClaudePlanWatcher() {
945
+ if (claudePlanWatcher) return;
946
+ try {
947
+ fs.mkdirSync(CLAUDE_PLANS_DIR, { recursive: true });
948
+ } catch (err) {
949
+ console.warn("[claude-plans] could not create storage dir:", err.message);
950
+ }
951
+ claudePlanWatcher = chokidar.watch(CLAUDE_PLANS_DIR, {
952
+ ignoreInitial: true,
953
+ awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 },
954
+ depth: 3
955
+ });
956
+ claudePlanWatcher.on("add", function (p) { handleWatcherChange("add", p); });
957
+ claudePlanWatcher.on("change", function (p) { handleWatcherChange("change", p); });
958
+ claudePlanWatcher.on("unlink", function (p) { handleWatcherChange("unlink", p); });
959
+ }
960
+
961
+ app.get("/claude-plans", function (req, res) {
962
+ res.sendFile(path.join(__dirname, "public", "claude-plans.html"));
963
+ });
964
+
965
+ app.get("/api/claude-plans", function (req, res) {
966
+ try {
967
+ res.json({ plans: listClaudePlans(), storage: CLAUDE_PLANS_DIR });
968
+ } catch (e) {
969
+ res.status(500).json({ error: e.message });
970
+ }
971
+ });
972
+
973
+ app.get("/api/claude-plans/stream", function (req, res) {
974
+ res.set({
975
+ "Content-Type": "text/event-stream",
976
+ "Cache-Control": "no-cache",
977
+ Connection: "keep-alive",
978
+ "X-Accel-Buffering": "no"
979
+ });
980
+ res.flushHeaders();
981
+ res.write("event: hello\ndata: {}\n\n");
982
+ claudePlanSseClients.add(res);
983
+
984
+ const heartbeat = setInterval(function () {
985
+ try {
986
+ res.write(": heartbeat\n\n");
987
+ } catch (err) {
988
+ clearInterval(heartbeat);
989
+ claudePlanSseClients.delete(res);
990
+ }
991
+ }, 25000);
992
+
993
+ req.on("close", function () {
994
+ clearInterval(heartbeat);
995
+ claudePlanSseClients.delete(res);
996
+ });
997
+ });
998
+
999
+ // :slug must avoid the static "stream" route above; Express matches in order.
1000
+ app.get("/api/claude-plans/:slug", function (req, res) {
1001
+ try {
1002
+ const slug = req.params.slug;
1003
+ if (!isValidSlug(slug)) return res.status(400).json({ error: "invalid slug" });
1004
+ const plan = safeReadJson(planFilePath(slug));
1005
+ if (!plan) return res.status(404).json({ error: "plan not found" });
1006
+ res.json({ plan: plan, artifacts: listClaudeArtifacts(slug) });
1007
+ } catch (e) {
1008
+ res.status(500).json({ error: e.message });
1009
+ }
1010
+ });
1011
+
1012
+ app.delete("/api/claude-plans/:slug", claudePlansLimiter, function (req, res) {
1013
+ try {
1014
+ var slug = req.params.slug;
1015
+ if (!isValidSlug(slug)) return res.status(400).json({ error: "invalid slug" });
1016
+ var baseDir = path.resolve(CLAUDE_PLANS_DIR);
1017
+ var planFile = path.resolve(baseDir, slug + ".json");
1018
+ if (!planFile.startsWith(baseDir + path.sep)) return res.status(400).json({ error: "invalid slug" });
1019
+ if (!fs.existsSync(planFile)) return res.status(404).json({ error: "plan not found" });
1020
+ fs.unlinkSync(planFile);
1021
+ var artifactsDir = path.resolve(baseDir, slug);
1022
+ if (artifactsDir.startsWith(baseDir + path.sep) && fs.existsSync(artifactsDir)) {
1023
+ fs.rmSync(artifactsDir, { recursive: true, force: true });
1024
+ }
1025
+ res.json({ deleted: true });
1026
+ } catch (e) {
1027
+ res.status(500).json({ error: e.message });
1028
+ }
1029
+ });
1030
+
775
1031
  // Only start the server when run directly (not when require()-d).
776
1032
  // Callers like dashboardCommand.ts and allScopesCommands.ts use
777
1033
  // spawn("node", [serverPath]) which sets require.main === module.
778
1034
  if (require.main === module) {
1035
+ startClaudePlanWatcher();
779
1036
  app.listen(PORT, "127.0.0.1", function () {
780
1037
  console.log("\n Dovetail Update Set Dashboard");
781
1038
  console.log(" Instance: " + SN_INSTANCE);
782
1039
  console.log(" Project: " + PROJECT_ROOT);
783
- console.log(" Dashboard: http://localhost:" + PORT + "\n");
1040
+ console.log(" Dashboard: http://localhost:" + PORT);
1041
+ console.log(" Claude: http://localhost:" + PORT + "/claude-plans\n");
784
1042
  });
785
1043
  }