@tenonhq/dovetail-dashboard 0.0.13

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 ADDED
@@ -0,0 +1,785 @@
1
+ const express = require("express");
2
+ const path = require("path");
3
+ const axios = require("axios");
4
+ const { wrapper } = require("axios-cookiejar-support");
5
+ const { CookieJar } = require("tough-cookie");
6
+ const fs = require("fs");
7
+ const RateLimit = require("express-rate-limit");
8
+
9
+ // Everything resolves from CWD — run this from your Dovetail project directory
10
+ const PROJECT_ROOT = process.cwd();
11
+ const envPath = path.resolve(PROJECT_ROOT, ".env");
12
+ require("dotenv").config({ path: envPath });
13
+
14
+ const app = express();
15
+ app.disable("x-powered-by");
16
+ const PORT = process.env.DASHBOARD_PORT || 3456;
17
+
18
+ const SN_INSTANCE = process.env.SN_INSTANCE || "";
19
+ const SN_USER = process.env.SN_USER || "";
20
+
21
+ // Rate limiter for recent-edits endpoint: max 100 requests per 15 minutes per IP
22
+ const recentEditsLimiter = RateLimit({
23
+ windowMs: 15 * 60 * 1000,
24
+ max: 100,
25
+ });
26
+ const SN_PASSWORD = process.env.SN_PASSWORD || "";
27
+ const BASE_URL = `https://${SN_INSTANCE}`;
28
+
29
+ // Resolve an artifact path, preferring the dove.* name and falling back to the
30
+ // legacy sinc.* name when only the legacy file exists.
31
+ function resolveDovePath(doveName, sincName) {
32
+ var dovePath = path.join(PROJECT_ROOT, doveName);
33
+ var sincPath = path.join(PROJECT_ROOT, sincName);
34
+ if (fs.existsSync(dovePath)) return dovePath;
35
+ if (fs.existsSync(sincPath)) return sincPath;
36
+ return dovePath;
37
+ }
38
+
39
+ const UPDATE_SET_CONFIG = resolveDovePath(".dove-update-sets.json", ".sinc-update-sets.json");
40
+ const DOVE_CONFIG_PATH = resolveDovePath("dove.config.js", "sinc.config.js");
41
+ const ACTIVE_TASK_FILE = resolveDovePath(".dove-active-task.json", ".sinc-active-task.json");
42
+
43
+ const CLICKUP_TOKEN = process.env.CLICKUP_API_TOKEN || "";
44
+ const CLICKUP_TEAM_ID = process.env.CLICKUP_TEAM_ID || "";
45
+
46
+ // Rate limiting for ServiceNow API calls.
47
+ // Dashboard uses 10 RPS so that combined with core's 20 RPS the total stays
48
+ // under ServiceNow's per-user throttle when both are active simultaneously.
49
+ var snRequestTimestamps = [];
50
+ var MAX_SN_RPS = 10;
51
+ var SN_WINDOW_MS = 1000;
52
+
53
+ function waitForRateLimit() {
54
+ var now = Date.now();
55
+ // Purge timestamps older than the window
56
+ snRequestTimestamps = snRequestTimestamps.filter(function (ts) {
57
+ return now - ts < SN_WINDOW_MS;
58
+ });
59
+
60
+ if (snRequestTimestamps.length < MAX_SN_RPS) {
61
+ snRequestTimestamps.push(now);
62
+ return Promise.resolve();
63
+ }
64
+
65
+ // Calculate how long to wait until the oldest request falls out of the window
66
+ var oldest = snRequestTimestamps[0];
67
+ var delayMs = SN_WINDOW_MS - (now - oldest) + 10; // +10ms buffer
68
+ return new Promise(function (resolve) {
69
+ setTimeout(function () {
70
+ snRequestTimestamps = snRequestTimestamps.filter(function (ts) {
71
+ return Date.now() - ts < SN_WINDOW_MS;
72
+ });
73
+ snRequestTimestamps.push(Date.now());
74
+ resolve();
75
+ }, delayMs);
76
+ });
77
+ }
78
+
79
+ app.use(express.json());
80
+ app.use(express.static(path.join(__dirname, "public")));
81
+
82
+ // Session-persistent ServiceNow client — cookie jar ensures scope changes
83
+ // (changeScope) persist across subsequent requests in the same session.
84
+ var snCookieJar = new CookieJar();
85
+ var snClient = wrapper(axios.create({
86
+ baseURL: BASE_URL,
87
+ auth: { username: SN_USER, password: SN_PASSWORD },
88
+ headers: {
89
+ "Content-Type": "application/json",
90
+ "Accept": "application/json",
91
+ },
92
+ jar: snCookieJar,
93
+ withCredentials: true,
94
+ }));
95
+
96
+ // Dovetail Scripted REST API rebrand: the API path moved from /api/cadso/claude/*
97
+ // to /api/cadso/dovetail/*. snApi rewrites legacy /api/cadso/claude/* URLs to the
98
+ // new path on first call; if that 404s (instance hasn't been re-imported yet) we
99
+ // latch back to the legacy path for the rest of the session and warn once.
100
+ var _dovetailApiUseLegacyClaudePath = false;
101
+
102
+ async function snApi(method, endpoint, data) {
103
+ await waitForRateLimit();
104
+ // Rewrite legacy /api/cadso/claude/* call sites to the new dovetail path,
105
+ // unless we've already discovered this instance only speaks the legacy path.
106
+ var rewritten = endpoint;
107
+ var isDovetailScopedApi = endpoint.indexOf("api/cadso/claude/") === 0
108
+ || endpoint.indexOf("/api/cadso/claude/") === 0
109
+ || endpoint.indexOf("api/cadso/dovetail/") === 0
110
+ || endpoint.indexOf("/api/cadso/dovetail/") === 0;
111
+ if (isDovetailScopedApi) {
112
+ rewritten = _dovetailApiUseLegacyClaudePath
113
+ ? endpoint.replace("api/cadso/dovetail/", "api/cadso/claude/")
114
+ : endpoint.replace("api/cadso/claude/", "api/cadso/dovetail/");
115
+ }
116
+ try {
117
+ return await snClient({ method: method, url: rewritten, data: data });
118
+ } catch (e) {
119
+ var status = e && e.response && e.response.status;
120
+ if (isDovetailScopedApi && !_dovetailApiUseLegacyClaudePath && status === 404) {
121
+ // eslint-disable-next-line no-console
122
+ console.warn(
123
+ "[deprecation] " + rewritten +
124
+ " returned 404. Falling back to legacy /api/cadso/claude/* path. Re-import the Dovetail Scripted REST API XML on your ServiceNow instance to silence this warning.",
125
+ );
126
+ _dovetailApiUseLegacyClaudePath = true;
127
+ var legacyUrl = rewritten.replace("api/cadso/dovetail/", "api/cadso/claude/");
128
+ return await snClient({ method: method, url: legacyUrl, data: data });
129
+ }
130
+ throw e;
131
+ }
132
+ }
133
+
134
+ // ClickUp API helper
135
+ function clickupApi(method, endpoint, data) {
136
+ return axios({
137
+ method,
138
+ url: "https://api.clickup.com/api/v2/" + endpoint,
139
+ headers: {
140
+ Authorization: CLICKUP_TOKEN,
141
+ "Content-Type": "application/json",
142
+ },
143
+ data,
144
+ });
145
+ }
146
+
147
+ // Generate update set name from ClickUp task
148
+ function generateUpdateSetName(taskId, taskName) {
149
+ var sanitized = taskName.replace(/[^a-zA-Z0-9\s\-_]/g, "").trim();
150
+ var base = "CU-" + taskId + " — " + sanitized;
151
+ return base.substring(0, 80);
152
+ }
153
+
154
+ // Generate update set description from task
155
+ function generateUpdateSetDescription(taskName, taskDescription) {
156
+ var desc = taskName;
157
+ if (taskDescription) {
158
+ var firstSentence = taskDescription.split(/[.!\n]/)[0].trim();
159
+ if (firstSentence) {
160
+ desc += " — " + firstSentence.substring(0, 150);
161
+ }
162
+ }
163
+ return desc;
164
+ }
165
+
166
+ // Read active task from persistence file
167
+ function readActiveTask() {
168
+ if (fs.existsSync(ACTIVE_TASK_FILE)) {
169
+ return JSON.parse(fs.readFileSync(ACTIVE_TASK_FILE, "utf8"));
170
+ }
171
+ return null;
172
+ }
173
+
174
+ // Write active task to persistence file
175
+ function writeActiveTask(task) {
176
+ fs.writeFileSync(ACTIVE_TASK_FILE, JSON.stringify(task, null, 2));
177
+ }
178
+
179
+ // Extract duplicate number from ServiceNow auto-numbered name
180
+ // "CU-abc — Name" => -1, "CU-abc — Name 1" => 1, "CU-abc — Name 2" => 2
181
+ function extractDuplicateNumber(name, baseName) {
182
+ if (name === baseName) return -1;
183
+ var suffix = name.substring(baseName.length).trim();
184
+ var num = parseInt(suffix, 10);
185
+ return isNaN(num) ? -1 : num;
186
+ }
187
+
188
+ // Find the best matching update set (highest duplicate number)
189
+ function findBestMatch(updateSets, baseName) {
190
+ var matches = updateSets.filter(function (us) {
191
+ return us.name === baseName || us.name.indexOf(baseName + " ") === 0;
192
+ });
193
+ if (matches.length === 0) return null;
194
+ if (matches.length === 1) return matches[0];
195
+
196
+ var best = matches[0];
197
+ var bestNum = extractDuplicateNumber(best.name, baseName);
198
+ for (var i = 1; i < matches.length; i++) {
199
+ var num = extractDuplicateNumber(matches[i].name, baseName);
200
+ if (num > bestNum) {
201
+ best = matches[i];
202
+ bestNum = num;
203
+ }
204
+ }
205
+ return best;
206
+ }
207
+
208
+ // GET /api/scopes — read from dove.config.js (or legacy sinc.config.js) + resolve display names
209
+ app.get("/api/scopes", async (req, res) => {
210
+ try {
211
+ delete require.cache[require.resolve(DOVE_CONFIG_PATH)];
212
+ const config = require(DOVE_CONFIG_PATH);
213
+ const scopeKeys = Object.keys(config.scopes || {});
214
+
215
+ // Batch query for all scope records
216
+ const scopeQuery = scopeKeys.map((s) => `scope=${s}`).join("^OR");
217
+ const resp = await snApi(
218
+ "get",
219
+ `api/now/table/sys_scope?sysparm_query=${encodeURIComponent(scopeQuery)}&sysparm_fields=sys_id,scope,name&sysparm_limit=50`
220
+ );
221
+
222
+ const scopeRecords = resp.data.result || [];
223
+ const scopeMap = {};
224
+ scopeRecords.forEach((r) => {
225
+ scopeMap[r.scope] = { sys_id: r.sys_id, name: r.name, scope: r.scope };
226
+ });
227
+
228
+ // Load saved selections
229
+ let saved = {};
230
+ if (fs.existsSync(UPDATE_SET_CONFIG)) {
231
+ saved = JSON.parse(fs.readFileSync(UPDATE_SET_CONFIG, "utf8"));
232
+ }
233
+
234
+ const scopes = scopeKeys.map((key) => ({
235
+ scope: key,
236
+ sys_id: scopeMap[key] ? scopeMap[key].sys_id : null,
237
+ display_name: scopeMap[key] ? scopeMap[key].name : key,
238
+ selected_update_set: saved[key] || null,
239
+ }));
240
+
241
+ res.json({ scopes });
242
+ } catch (e) {
243
+ res.status(500).json({ error: e.message });
244
+ }
245
+ });
246
+
247
+ // GET /api/recent-edits — read local recent edits file, enrich with live SN data
248
+ var RECENT_EDITS_FILE = resolveDovePath(".dove-recent-edits.json", ".sinc-recent-edits.json");
249
+
250
+ app.get("/api/recent-edits", recentEditsLimiter, async function (req, res) {
251
+ try {
252
+ var edits = [];
253
+ if (fs.existsSync(RECENT_EDITS_FILE)) {
254
+ edits = JSON.parse(fs.readFileSync(RECENT_EDITS_FILE, "utf8"));
255
+ }
256
+
257
+ if (edits.length === 0) {
258
+ return res.json({ edits: [] });
259
+ }
260
+
261
+ // For each edit, query sys_update_xml to get the live update set
262
+ var enriched = [];
263
+ for (var i = 0; i < edits.length; i++) {
264
+ var edit = edits[i];
265
+ var updateSetName = "unknown";
266
+ try {
267
+ var query = "name=" + edit.tableName + "_" + edit.sys_id + "^ORDERBYDESCsys_created_on";
268
+ var snResp = await snApi(
269
+ "get",
270
+ "api/now/table/sys_update_xml?sysparm_query=" + encodeURIComponent(query) +
271
+ "&sysparm_fields=update_set,update_set.name&sysparm_limit=1"
272
+ );
273
+ var results = snResp.data.result || [];
274
+ if (results.length > 0) {
275
+ updateSetName = results[0]["update_set.name"] || "unknown";
276
+ }
277
+ } catch (snErr) {
278
+ // If SN query fails, still show the entry with "unknown" update set
279
+ }
280
+
281
+ enriched.push({
282
+ tableName: edit.tableName,
283
+ name: edit.name,
284
+ scope: edit.scope,
285
+ updateSet: updateSetName,
286
+ timestamp: edit.timestamp,
287
+ });
288
+ }
289
+
290
+ res.json({ edits: enriched });
291
+ } catch (e) {
292
+ res.status(500).json({ error: e.message });
293
+ }
294
+ });
295
+
296
+ // GET /api/update-sets/:scope — list in-progress update sets for a scope
297
+ app.get("/api/update-sets/:scope", async (req, res) => {
298
+ try {
299
+ const { scope } = req.params;
300
+ const query = `application.scope=${scope}^state=in progress^ORDERBYDESCsys_created_on`;
301
+ const resp = await snApi(
302
+ "get",
303
+ `api/now/table/sys_update_set?sysparm_query=${encodeURIComponent(query)}&sysparm_fields=sys_id,name,state,application,sys_created_on,description&sysparm_limit=50`
304
+ );
305
+ res.json({ update_sets: resp.data.result || [] });
306
+ } catch (e) {
307
+ res.status(500).json({ error: e.message });
308
+ }
309
+ });
310
+
311
+ // POST /api/update-set — create a new update set
312
+ app.post("/api/update-set", async (req, res) => {
313
+ try {
314
+ const { name, scope, scope_sys_id, description } = req.body;
315
+ if (!name || typeof name !== "string" || name.trim() === "") {
316
+ return res.status(400).json({ error: "name is required" });
317
+ }
318
+ if (!scope_sys_id || typeof scope_sys_id !== "string") {
319
+ return res.status(400).json({ error: "scope_sys_id is required" });
320
+ }
321
+
322
+ // Switch to the target scope before creating — ServiceNow uses session scope
323
+ if (scope) {
324
+ await snApi("get", "api/cadso/claude/changeScope?scope=" + encodeURIComponent(scope));
325
+ }
326
+
327
+ const data = {
328
+ name,
329
+ state: "in progress",
330
+ application: scope_sys_id,
331
+ };
332
+ if (description) data.description = description;
333
+
334
+ const resp = await snApi("post", "api/now/table/sys_update_set", data);
335
+ res.json({ update_set: resp.data.result });
336
+ } catch (e) {
337
+ res.status(500).json({ error: e.message });
338
+ }
339
+ });
340
+
341
+ // PATCH /api/update-set/:sysId/close — close an update set
342
+ app.patch("/api/update-set/:sysId/close", async (req, res) => {
343
+ try {
344
+ const { sysId } = req.params;
345
+ const resp = await snApi(
346
+ "patch",
347
+ `api/now/table/sys_update_set/${sysId}`,
348
+ { state: "complete" }
349
+ );
350
+ res.json({ update_set: resp.data.result });
351
+ } catch (e) {
352
+ res.status(500).json({ error: e.message });
353
+ }
354
+ });
355
+
356
+ // POST /api/select-update-set — save scope->updateSet mapping
357
+ app.post("/api/select-update-set", async (req, res) => {
358
+ try {
359
+ const { scope, update_set_sys_id, update_set_name } = req.body;
360
+ if (!scope || typeof scope !== "string") {
361
+ return res.status(400).json({ error: "scope is required" });
362
+ }
363
+
364
+ let config = {};
365
+ if (fs.existsSync(UPDATE_SET_CONFIG)) {
366
+ config = JSON.parse(fs.readFileSync(UPDATE_SET_CONFIG, "utf8"));
367
+ }
368
+
369
+ if (update_set_sys_id) {
370
+ config[scope] = { sys_id: update_set_sys_id, name: update_set_name };
371
+ } else {
372
+ delete config[scope];
373
+ }
374
+
375
+ fs.writeFileSync(UPDATE_SET_CONFIG, JSON.stringify(config, null, 2));
376
+ res.json({ saved: true, config });
377
+ } catch (e) {
378
+ res.status(500).json({ error: e.message });
379
+ }
380
+ });
381
+
382
+ // GET /api/config — return current saved config
383
+ app.get("/api/config", (req, res) => {
384
+ try {
385
+ let config = {};
386
+ if (fs.existsSync(UPDATE_SET_CONFIG)) {
387
+ config = JSON.parse(fs.readFileSync(UPDATE_SET_CONFIG, "utf8"));
388
+ }
389
+ res.json({ config, instance: SN_INSTANCE });
390
+ } catch (e) {
391
+ res.status(500).json({ error: e.message });
392
+ }
393
+ });
394
+
395
+ // --- ClickUp Endpoints ---
396
+
397
+ // GET /api/clickup/status — check if ClickUp is configured + active task
398
+ app.get("/api/clickup/status", function (req, res) {
399
+ try {
400
+ var activeTask = readActiveTask();
401
+ res.json({
402
+ configured: !!(CLICKUP_TOKEN && CLICKUP_TOKEN.length > 0),
403
+ hasTeamId: !!(CLICKUP_TEAM_ID && CLICKUP_TEAM_ID.length > 0),
404
+ activeTask: activeTask,
405
+ });
406
+ } catch (e) {
407
+ res.status(500).json({ error: e.message });
408
+ }
409
+ });
410
+
411
+ // GET /api/clickup/me — fetch current ClickUp user
412
+ app.get("/api/clickup/me", async function (req, res) {
413
+ try {
414
+ if (!CLICKUP_TOKEN) {
415
+ return res.status(400).json({ error: "CLICKUP_API_TOKEN not configured" });
416
+ }
417
+ var resp = await clickupApi("get", "user");
418
+ var user = resp.data.user || {};
419
+ res.json({
420
+ id: user.id,
421
+ username: user.username || "",
422
+ email: user.email || "",
423
+ initials: user.initials || "",
424
+ });
425
+ } catch (e) {
426
+ var msg = e.message;
427
+ if (e.response && e.response.data) {
428
+ msg = e.response.data.err || e.response.data.error || msg;
429
+ }
430
+ res.status(500).json({ error: msg });
431
+ }
432
+ });
433
+
434
+ // GET /api/clickup/tasks — fetch user's tasks with optional status filter
435
+ app.get("/api/clickup/tasks", async function (req, res) {
436
+ try {
437
+ if (!CLICKUP_TOKEN) {
438
+ return res.status(400).json({ error: "CLICKUP_API_TOKEN not configured" });
439
+ }
440
+
441
+ var teamId = CLICKUP_TEAM_ID;
442
+ if (!teamId) {
443
+ var teamsResp = await clickupApi("get", "team");
444
+ var teams = teamsResp.data.teams || [];
445
+ if (teams.length === 0) {
446
+ return res.status(400).json({ error: "No ClickUp teams found" });
447
+ }
448
+ teamId = teams[0].id;
449
+ }
450
+
451
+ var statuses = req.query.statuses;
452
+ var statusList = statuses ? statuses.split(",") : [];
453
+
454
+ var url = "team/" + teamId + "/task?subtasks=true&include_closed=false";
455
+ statusList.forEach(function (s) {
456
+ url += "&statuses[]=" + encodeURIComponent(s.trim());
457
+ });
458
+
459
+ var resp = await clickupApi("get", url);
460
+ var tasks = resp.data.tasks || [];
461
+
462
+ // Group by status
463
+ var byStatus = {};
464
+ var allStatuses = [];
465
+ tasks.forEach(function (t) {
466
+ var statusName = t.status && t.status.status ? t.status.status : "unknown";
467
+ if (!byStatus[statusName]) {
468
+ byStatus[statusName] = [];
469
+ allStatuses.push(statusName);
470
+ }
471
+ var assignees = (t.assignees || []).map(function (a) {
472
+ return {
473
+ id: a.id,
474
+ username: a.username || "",
475
+ initials: a.initials || "",
476
+ };
477
+ });
478
+ byStatus[statusName].push({
479
+ id: t.id,
480
+ name: t.name,
481
+ description: t.description || "",
482
+ status: statusName,
483
+ statusColor: t.status && t.status.color ? t.status.color : null,
484
+ priority: t.priority ? t.priority.priority : null,
485
+ url: t.url || "",
486
+ customId: t.custom_id || null,
487
+ assignees: assignees,
488
+ });
489
+ });
490
+
491
+ res.json({ tasks: tasks.length, byStatus: byStatus, statuses: allStatuses });
492
+ } catch (e) {
493
+ var msg = e.message;
494
+ if (e.response && e.response.data) {
495
+ msg = e.response.data.err || e.response.data.error || msg;
496
+ }
497
+ res.status(500).json({ error: msg });
498
+ }
499
+ });
500
+
501
+ // GET /api/clickup/task/:taskId — fetch single task detail
502
+ app.get("/api/clickup/task/:taskId", async function (req, res) {
503
+ try {
504
+ if (!CLICKUP_TOKEN) {
505
+ return res.status(400).json({ error: "CLICKUP_API_TOKEN not configured" });
506
+ }
507
+ var resp = await clickupApi("get", "task/" + req.params.taskId);
508
+ var t = resp.data;
509
+ res.json({
510
+ task: {
511
+ id: t.id,
512
+ name: t.name,
513
+ description: t.description || "",
514
+ status: t.status && t.status.status ? t.status.status : "unknown",
515
+ statusColor: t.status && t.status.color ? t.status.color : null,
516
+ priority: t.priority ? t.priority.priority : null,
517
+ url: t.url || "",
518
+ customId: t.custom_id || null,
519
+ },
520
+ });
521
+ } catch (e) {
522
+ var msg = e.message;
523
+ if (e.response && e.response.data) {
524
+ msg = e.response.data.err || e.response.data.error || msg;
525
+ }
526
+ res.status(500).json({ error: msg });
527
+ }
528
+ });
529
+
530
+ // POST /api/clickup/select-task — select a task as active
531
+ app.post("/api/clickup/select-task", function (req, res) {
532
+ try {
533
+ var body = req.body;
534
+ if (!body.taskId || !body.taskName) {
535
+ return res.status(400).json({ error: "taskId and taskName are required" });
536
+ }
537
+
538
+ var updateSetName = generateUpdateSetName(body.taskId, body.taskName);
539
+ var description = generateUpdateSetDescription(
540
+ body.taskName,
541
+ body.taskDescription || ""
542
+ );
543
+
544
+ var activeTask = {
545
+ taskId: body.taskId,
546
+ taskName: body.taskName,
547
+ taskDescription: body.taskDescription || "",
548
+ updateSetName: updateSetName,
549
+ description: description,
550
+ taskUrl: body.taskUrl || "",
551
+ scopes: {},
552
+ };
553
+
554
+ writeActiveTask(activeTask);
555
+ res.json({ activeTask: activeTask });
556
+ } catch (e) {
557
+ res.status(500).json({ error: e.message });
558
+ }
559
+ });
560
+
561
+ // Core logic: find or create update set for a scope given an active task
562
+ // Returns { update_set, created }
563
+ async function findOrCreateUpdateSet(scope, scopeSysId, activeTask) {
564
+ var baseName = activeTask.updateSetName;
565
+ var taskId = activeTask.taskId;
566
+
567
+ if (!taskId || typeof taskId !== "string" || taskId.trim() === "") {
568
+ throw new Error("Cannot search for update sets: activeTask has an empty or missing taskId");
569
+ }
570
+
571
+ // Query ServiceNow for existing update sets matching this task in this scope
572
+ var query =
573
+ "application.scope=" + scope +
574
+ "^nameLIKECU-" + taskId +
575
+ "^state=in progress" +
576
+ "^ORDERBYDESCsys_created_on";
577
+ var searchResp = await snApi(
578
+ "get",
579
+ "api/now/table/sys_update_set?sysparm_query=" +
580
+ encodeURIComponent(query) +
581
+ "&sysparm_fields=sys_id,name,state,application,sys_created_on,description&sysparm_limit=50"
582
+ );
583
+ var existing = searchResp.data.result || [];
584
+
585
+ var updateSet = null;
586
+
587
+ if (existing.length > 0) {
588
+ updateSet = findBestMatch(existing, baseName);
589
+ if (!updateSet) {
590
+ updateSet = existing[0];
591
+ }
592
+ }
593
+
594
+ var created = false;
595
+ if (!updateSet) {
596
+ // Switch to the target scope before creating — ServiceNow uses session scope,
597
+ // not the application field, when creating update sets via Table API
598
+ await snApi("get", "api/cadso/claude/changeScope?scope=" + encodeURIComponent(scope));
599
+
600
+ var createData = {
601
+ name: baseName,
602
+ state: "in progress",
603
+ application: scopeSysId,
604
+ };
605
+ if (activeTask.description) {
606
+ createData.description = activeTask.description;
607
+ }
608
+ var createResp = await snApi("post", "api/now/table/sys_update_set", createData);
609
+ updateSet = createResp.data.result;
610
+ created = true;
611
+ }
612
+
613
+ // Change the current update set on the ServiceNow instance and verify
614
+ try {
615
+ await snApi(
616
+ "get",
617
+ "api/cadso/claude/changeUpdateSet?sysId=" + encodeURIComponent(updateSet.sys_id)
618
+ );
619
+
620
+ // Verify the switch was successful
621
+ var verifyResp = await snApi(
622
+ "get",
623
+ "api/cadso/claude/currentUpdateSet" + (scope ? "?scope=" + encodeURIComponent(scope) : "")
624
+ );
625
+ var verifyData = verifyResp.data;
626
+ if (verifyData && verifyData.result) {
627
+ verifyData = verifyData.result;
628
+ }
629
+ var currentSysId = verifyData && verifyData.sysId ? verifyData.sysId : null;
630
+ if (currentSysId !== updateSet.sys_id) {
631
+ // Retry once
632
+ console.warn("Update set verification failed, retrying switch...");
633
+ await snApi(
634
+ "get",
635
+ "api/cadso/claude/changeUpdateSet?sysId=" + encodeURIComponent(updateSet.sys_id)
636
+ );
637
+ var retryResp = await snApi(
638
+ "get",
639
+ "api/cadso/claude/currentUpdateSet" + (scope ? "?scope=" + encodeURIComponent(scope) : "")
640
+ );
641
+ var retryData = retryResp.data;
642
+ if (retryData && retryData.result) {
643
+ retryData = retryData.result;
644
+ }
645
+ var retrySysId = retryData && retryData.sysId ? retryData.sysId : null;
646
+ if (retrySysId !== updateSet.sys_id) {
647
+ var actualName = retryData && retryData.name ? retryData.name : "unknown";
648
+ console.error(
649
+ "Update set " + updateSet.name + " was created but could not be activated. Current update set is " + actualName + "."
650
+ );
651
+ }
652
+ }
653
+ } catch (changeErr) {
654
+ console.error("Warning: Could not auto-switch update set on instance:", changeErr.message);
655
+ }
656
+
657
+ return { update_set: updateSet, created: created };
658
+ }
659
+
660
+ // Persist scope activation into both active task file and update set config
661
+ function persistScopeActivation(scope, updateSet, activeTask) {
662
+ activeTask.scopes[scope] = {
663
+ sys_id: updateSet.sys_id,
664
+ name: updateSet.name,
665
+ };
666
+ writeActiveTask(activeTask);
667
+
668
+ var config = {};
669
+ if (fs.existsSync(UPDATE_SET_CONFIG)) {
670
+ config = JSON.parse(fs.readFileSync(UPDATE_SET_CONFIG, "utf8"));
671
+ }
672
+ config[scope] = {
673
+ sys_id: updateSet.sys_id,
674
+ name: updateSet.name,
675
+ };
676
+ fs.writeFileSync(UPDATE_SET_CONFIG, JSON.stringify(config, null, 2));
677
+ }
678
+
679
+ // POST /api/clickup/activate-scope — find or create update set for a scope
680
+ app.post("/api/clickup/activate-scope", async function (req, res) {
681
+ try {
682
+ var body = req.body;
683
+ if (!body.scope || !body.scope_sys_id) {
684
+ return res.status(400).json({ error: "scope and scope_sys_id are required" });
685
+ }
686
+
687
+ var activeTask = readActiveTask();
688
+ if (!activeTask) {
689
+ return res.status(400).json({ error: "No active task selected" });
690
+ }
691
+
692
+ var result = await findOrCreateUpdateSet(body.scope, body.scope_sys_id, activeTask);
693
+ persistScopeActivation(body.scope, result.update_set, activeTask);
694
+
695
+ res.json({
696
+ update_set: result.update_set,
697
+ created: result.created,
698
+ scope: body.scope,
699
+ });
700
+ } catch (e) {
701
+ res.status(500).json({ error: e.message });
702
+ }
703
+ });
704
+
705
+ // POST /api/clickup/activate-all-scopes — find or create update sets for all configured scopes
706
+ app.post("/api/clickup/activate-all-scopes", async function (req, res) {
707
+ try {
708
+ var activeTask = readActiveTask();
709
+ if (!activeTask) {
710
+ return res.status(400).json({ error: "No active task selected" });
711
+ }
712
+
713
+ // Read scopes from dove.config.js
714
+ delete require.cache[require.resolve(DOVE_CONFIG_PATH)];
715
+ var config = require(DOVE_CONFIG_PATH);
716
+ var scopeKeys = Object.keys(config.scopes || {});
717
+
718
+ // Resolve scope sys_ids
719
+ var scopeQuery = scopeKeys.map(function (s) { return "scope=" + s; }).join("^OR");
720
+ var scopeResp = await snApi(
721
+ "get",
722
+ "api/now/table/sys_scope?sysparm_query=" +
723
+ encodeURIComponent(scopeQuery) +
724
+ "&sysparm_fields=sys_id,scope,name&sysparm_limit=50"
725
+ );
726
+ var scopeRecords = scopeResp.data.result || [];
727
+ var scopeMap = {};
728
+ scopeRecords.forEach(function (r) {
729
+ scopeMap[r.scope] = r.sys_id;
730
+ });
731
+
732
+ // Activate each scope sequentially (respects rate limits)
733
+ var results = [];
734
+ for (var i = 0; i < scopeKeys.length; i++) {
735
+ var scope = scopeKeys[i];
736
+ var scopeSysId = scopeMap[scope];
737
+ if (!scopeSysId) {
738
+ results.push({ scope: scope, error: "scope not found on instance" });
739
+ continue;
740
+ }
741
+
742
+ try {
743
+ var result = await findOrCreateUpdateSet(scope, scopeSysId, activeTask);
744
+ persistScopeActivation(scope, result.update_set, activeTask);
745
+ // Re-read active task so subsequent iterations see updated scopes
746
+ activeTask = readActiveTask();
747
+ results.push({
748
+ scope: scope,
749
+ update_set: result.update_set,
750
+ created: result.created,
751
+ });
752
+ } catch (scopeErr) {
753
+ results.push({ scope: scope, error: scopeErr.message });
754
+ }
755
+ }
756
+
757
+ res.json({ results: results, activeTask: readActiveTask() });
758
+ } catch (e) {
759
+ res.status(500).json({ error: e.message });
760
+ }
761
+ });
762
+
763
+ // POST /api/clickup/deselect-task — clear the active task
764
+ app.post("/api/clickup/deselect-task", function (req, res) {
765
+ try {
766
+ if (fs.existsSync(ACTIVE_TASK_FILE)) {
767
+ fs.unlinkSync(ACTIVE_TASK_FILE);
768
+ }
769
+ res.json({ cleared: true });
770
+ } catch (e) {
771
+ res.status(500).json({ error: e.message });
772
+ }
773
+ });
774
+
775
+ // Only start the server when run directly (not when require()-d).
776
+ // Callers like dashboardCommand.ts and allScopesCommands.ts use
777
+ // spawn("node", [serverPath]) which sets require.main === module.
778
+ if (require.main === module) {
779
+ app.listen(PORT, "127.0.0.1", function () {
780
+ console.log("\n Dovetail Update Set Dashboard");
781
+ console.log(" Instance: " + SN_INSTANCE);
782
+ console.log(" Project: " + PROJECT_ROOT);
783
+ console.log(" Dashboard: http://localhost:" + PORT + "\n");
784
+ });
785
+ }