@rui.branco/jira-mcp 1.7.10 → 1.7.11

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.
Files changed (4) hide show
  1. package/config-store.js +553 -0
  2. package/index.js +492 -149
  3. package/package.json +3 -2
  4. package/setup.js +129 -89
@@ -0,0 +1,553 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const crypto = require("crypto");
4
+ const os = require("os");
5
+
6
+ const configPath = path.resolve(
7
+ process.env.JIRA_MCP_CONFIG_PATH ||
8
+ path.join(process.env.HOME, ".config/jira-mcp/config.json"),
9
+ );
10
+ const configuredLockWaitMs = Number(process.env.JIRA_MCP_LOCK_WAIT_MS);
11
+ const LOCK_WAIT_MS =
12
+ Number.isInteger(configuredLockWaitMs) && configuredLockWaitMs > 0
13
+ ? configuredLockWaitMs
14
+ : 5000;
15
+ const LOCK_RETRY_MS = 25;
16
+ const LOCK_STALE_MS = 30000;
17
+ const HOSTNAME = os.hostname();
18
+ const waitBuffer = new Int32Array(new SharedArrayBuffer(4));
19
+ const UNSUPPORTED_FS_ERRORS = new Set([
20
+ "EINVAL",
21
+ "EISDIR",
22
+ "ENOSYS",
23
+ "ENOTSUP",
24
+ "EOPNOTSUPP",
25
+ ]);
26
+
27
+ function normalizeProjects(projects) {
28
+ return [...new Set(
29
+ (Array.isArray(projects) ? projects : [])
30
+ .filter((project) => typeof project === "string")
31
+ .map((project) => project.trim().toUpperCase())
32
+ .filter(Boolean),
33
+ )];
34
+ }
35
+
36
+ function resolveConfigTargetPath() {
37
+ let current = configPath;
38
+ const seen = new Set();
39
+ while (true) {
40
+ if (seen.has(current)) {
41
+ throw new Error(`Jira config path contains a symlink cycle: ${configPath}.`);
42
+ }
43
+ seen.add(current);
44
+
45
+ let stat;
46
+ try {
47
+ stat = fs.lstatSync(current);
48
+ } catch (error) {
49
+ if (error.code !== "ENOENT") throw error;
50
+ let parent = path.dirname(current);
51
+ try {
52
+ parent = fs.realpathSync(parent);
53
+ } catch (parentError) {
54
+ if (parentError.code !== "ENOENT") throw parentError;
55
+ }
56
+ return path.join(parent, path.basename(current));
57
+ }
58
+
59
+ if (!stat.isSymbolicLink()) {
60
+ try {
61
+ return fs.realpathSync(current);
62
+ } catch (error) {
63
+ if (error.code !== "ENOENT") throw error;
64
+ return current;
65
+ }
66
+ }
67
+ current = path.resolve(path.dirname(current), fs.readlinkSync(current));
68
+ }
69
+ }
70
+
71
+ function isUnsupportedFsError(error) {
72
+ return UNSUPPORTED_FS_ERRORS.has(error.code) ||
73
+ (process.platform === "win32" && error.code === "EPERM");
74
+ }
75
+
76
+ function sleepSync(milliseconds) {
77
+ Atomics.wait(waitBuffer, 0, 0, milliseconds);
78
+ }
79
+
80
+ function createOwnershipMetadata() {
81
+ const createdAt = Date.now();
82
+ return {
83
+ owner: `${process.pid}-${createdAt}-${crypto.randomBytes(16).toString("hex")}`,
84
+ pid: process.pid,
85
+ hostname: HOSTNAME,
86
+ createdAt,
87
+ state: "held",
88
+ };
89
+ }
90
+
91
+ function syncFile(fd) {
92
+ try {
93
+ fs.fsyncSync(fd);
94
+ } catch (error) {
95
+ if (!isUnsupportedFsError(error)) throw error;
96
+ }
97
+ }
98
+
99
+ function writeClaimMetadata(fd, metadata) {
100
+ const contents = `${JSON.stringify(metadata)}\n`;
101
+ const written = fs.writeSync(fd, contents, 0, "utf8");
102
+ if (written !== Buffer.byteLength(contents)) {
103
+ throw new Error("Could not write the Jira config lock claim completely.");
104
+ }
105
+ syncFile(fd);
106
+ }
107
+
108
+ function parseClaimMetadata(contents) {
109
+ try {
110
+ const metadata = JSON.parse(contents);
111
+ return metadata && typeof metadata === "object" && !Array.isArray(metadata)
112
+ ? metadata
113
+ : null;
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ function isValidClaimMetadata(metadata) {
120
+ return Boolean(
121
+ metadata &&
122
+ typeof metadata.owner === "string" &&
123
+ metadata.owner.length > 0 &&
124
+ Number.isSafeInteger(metadata.pid) &&
125
+ metadata.pid > 0 &&
126
+ typeof metadata.hostname === "string" &&
127
+ metadata.hostname.length > 0 &&
128
+ typeof metadata.createdAt === "number" &&
129
+ Number.isFinite(metadata.createdAt) &&
130
+ (metadata.state === "held" || metadata.state === "released"),
131
+ );
132
+ }
133
+
134
+ function readClaimSnapshot(claimPath) {
135
+ let stat;
136
+ try {
137
+ stat = fs.statSync(claimPath);
138
+ } catch (error) {
139
+ if (error.code === "ENOENT") return null;
140
+ throw error;
141
+ }
142
+ if (!stat.isFile()) return { stat, contents: null, metadata: null };
143
+
144
+ let contents;
145
+ try {
146
+ contents = fs.readFileSync(claimPath, "utf8");
147
+ } catch (error) {
148
+ if (error.code === "ENOENT") return null;
149
+ throw error;
150
+ }
151
+ return {
152
+ stat,
153
+ contents,
154
+ metadata: parseClaimMetadata(contents),
155
+ };
156
+ }
157
+
158
+ function sameFile(statA, statB) {
159
+ return statA.dev === statB.dev && statA.ino === statB.ino;
160
+ }
161
+
162
+ function sameClaimSnapshot(expected, current) {
163
+ return sameFile(expected.stat, current.stat) &&
164
+ expected.contents === current.contents;
165
+ }
166
+
167
+ function isOlderThanStale(snapshot) {
168
+ const timestamp = isValidClaimMetadata(snapshot.metadata)
169
+ ? snapshot.metadata.createdAt
170
+ : snapshot.stat.mtimeMs;
171
+ return Date.now() - timestamp > LOCK_STALE_MS;
172
+ }
173
+
174
+ function listClaims(lockPath) {
175
+ let names;
176
+ try {
177
+ names = fs.readdirSync(lockPath);
178
+ } catch (error) {
179
+ if (error.code === "ENOENT") return [];
180
+ throw error;
181
+ }
182
+
183
+ return names
184
+ .filter((name) => /^\d+$/.test(name))
185
+ .map((name) => ({
186
+ ticket: BigInt(name),
187
+ claimPath: path.join(lockPath, name),
188
+ }))
189
+ .filter(({ ticket }) => ticket > 0n)
190
+ .sort((left, right) => left.ticket < right.ticket ? -1 : 1);
191
+ }
192
+
193
+ function ensureLockDirectory(lockPath) {
194
+ try {
195
+ fs.mkdirSync(lockPath, { mode: 0o700 });
196
+ } catch (error) {
197
+ if (error.code !== "EEXIST") throw error;
198
+ if (!fs.statSync(lockPath).isDirectory()) {
199
+ throw new Error(`Jira config lock path "${lockPath}" is not a directory.`);
200
+ }
201
+ }
202
+ }
203
+
204
+ function lockTimeout(lockPath) {
205
+ return new Error(`Timed out waiting for the Jira config lock "${lockPath}".`);
206
+ }
207
+
208
+ function allocateClaimSync(lockPath, deadline) {
209
+ while (true) {
210
+ if (Date.now() >= deadline) throw lockTimeout(lockPath);
211
+ ensureLockDirectory(lockPath);
212
+
213
+ const claims = listClaims(lockPath);
214
+ const maxTicket = claims.reduce(
215
+ (max, claim) => claim.ticket > max ? claim.ticket : max,
216
+ 0n,
217
+ );
218
+ const ticket = maxTicket + 1n;
219
+ const claimPath = path.join(lockPath, ticket.toString());
220
+ let fd = null;
221
+ try {
222
+ const metadata = createOwnershipMetadata();
223
+ fd = fs.openSync(claimPath, "wx", 0o600);
224
+ writeClaimMetadata(fd, metadata);
225
+ return {
226
+ fd,
227
+ lockPath,
228
+ claimPath,
229
+ ticket,
230
+ owner: metadata.owner,
231
+ metadata,
232
+ };
233
+ } catch (error) {
234
+ if (fd !== null) {
235
+ try { fs.closeSync(fd); } catch {}
236
+ throw error;
237
+ }
238
+ if (error.code !== "EEXIST" && error.code !== "ENOENT") throw error;
239
+ if (Date.now() >= deadline) throw lockTimeout(lockPath);
240
+ sleepSync(Math.min(LOCK_RETRY_MS, deadline - Date.now()));
241
+ }
242
+ }
243
+ }
244
+
245
+ function isPidAlive(pid) {
246
+ try {
247
+ process.kill(pid, 0);
248
+ return true;
249
+ } catch (error) {
250
+ return error.code !== "ESRCH";
251
+ }
252
+ }
253
+
254
+ function getClaimStatus(snapshot) {
255
+ if (!snapshot) return "absent";
256
+ if (!isValidClaimMetadata(snapshot.metadata)) {
257
+ return isOlderThanStale(snapshot) ? "stale" : "waiting";
258
+ }
259
+ if (snapshot.metadata.state === "released") return "released";
260
+
261
+ if (snapshot.metadata.hostname === HOSTNAME) {
262
+ return isPidAlive(snapshot.metadata.pid) ? "waiting" : "stale";
263
+ }
264
+ // A PID on another host cannot be checked safely. Fail closed rather than
265
+ // reclaiming a potentially live claim on a shared filesystem.
266
+ return "waiting";
267
+ }
268
+
269
+ function unlinkClaimIfUnchanged(snapshot) {
270
+ if (!snapshot.stat.isFile()) return;
271
+ const current = readClaimSnapshot(snapshot.claimPath);
272
+ if (!current || !sameClaimSnapshot(snapshot, current)) return;
273
+ try {
274
+ fs.unlinkSync(snapshot.claimPath);
275
+ } catch {}
276
+ }
277
+
278
+ function waitForLowerClaims(claim, deadline) {
279
+ while (true) {
280
+ let blocked = false;
281
+ const lowerClaims = listClaims(claim.lockPath)
282
+ .filter(({ ticket }) => ticket < claim.ticket);
283
+ for (const lowerClaim of lowerClaims) {
284
+ const snapshot = readClaimSnapshot(lowerClaim.claimPath);
285
+ const status = getClaimStatus(snapshot);
286
+ if (status === "stale" || status === "released") {
287
+ if (snapshot) unlinkClaimIfUnchanged({ ...snapshot, claimPath: lowerClaim.claimPath });
288
+ } else if (status === "waiting") {
289
+ blocked = true;
290
+ }
291
+ }
292
+ if (!blocked) return;
293
+ if (Date.now() >= deadline) throw lockTimeout(claim.lockPath);
294
+ sleepSync(Math.min(LOCK_RETRY_MS, deadline - Date.now()));
295
+ }
296
+ }
297
+
298
+ function releaseClaim(claim) {
299
+ try {
300
+ const snapshot = readClaimSnapshot(claim.claimPath);
301
+ const fdStat = fs.fstatSync(claim.fd);
302
+ if (
303
+ !snapshot ||
304
+ !snapshot.metadata ||
305
+ snapshot.metadata.owner !== claim.owner ||
306
+ !sameFile(fdStat, snapshot.stat)
307
+ ) {
308
+ return;
309
+ }
310
+
311
+ let releaseError = null;
312
+ try {
313
+ const releasedMetadata = { ...snapshot.metadata, state: "released" };
314
+ fs.ftruncateSync(claim.fd, 0);
315
+ writeClaimMetadata(claim.fd, releasedMetadata);
316
+ } catch (error) {
317
+ releaseError = error;
318
+ }
319
+
320
+ let unlinked = false;
321
+ try {
322
+ const current = readClaimSnapshot(claim.claimPath);
323
+ if (current && sameFile(fdStat, current.stat)) {
324
+ fs.unlinkSync(claim.claimPath);
325
+ unlinked = true;
326
+ }
327
+ } catch (error) {
328
+ if (!releaseError) releaseError = error;
329
+ }
330
+
331
+ // Either a durable released marker or an absent claim lets later tickets
332
+ // proceed. Only surface a release error if neither outcome was achieved.
333
+ if (releaseError && !unlinked) {
334
+ const status = getClaimStatus(readClaimSnapshot(claim.claimPath));
335
+ if (status !== "released" && status !== "absent") throw releaseError;
336
+ }
337
+ try { fs.rmdirSync(claim.lockPath); } catch {}
338
+ } finally {
339
+ try { fs.closeSync(claim.fd); } catch {}
340
+ }
341
+ }
342
+
343
+ function acquireLockSync(targetPath) {
344
+ const lockPath = `${targetPath}.lock`;
345
+ const deadline = Date.now() + LOCK_WAIT_MS;
346
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
347
+ const claim = allocateClaimSync(lockPath, deadline);
348
+ claim.targetPath = targetPath;
349
+ try {
350
+ waitForLowerClaims(claim, deadline);
351
+ return claim;
352
+ } catch (error) {
353
+ try { releaseClaim(claim); } catch {}
354
+ throw error;
355
+ }
356
+ }
357
+
358
+ function inspectConfigTarget(targetPath) {
359
+ let stat;
360
+ try {
361
+ stat = fs.statSync(targetPath);
362
+ } catch (error) {
363
+ if (error.code === "ENOENT") return null;
364
+ throw error;
365
+ }
366
+ if (!stat.isFile()) {
367
+ throw new Error(`Jira config target "${targetPath}" is not a regular file.`);
368
+ }
369
+ if (
370
+ typeof process.getuid === "function" &&
371
+ typeof stat.uid === "number" &&
372
+ stat.uid !== process.getuid()
373
+ ) {
374
+ throw new Error(`Jira config target "${targetPath}" is not owned by the current user.`);
375
+ }
376
+ return stat;
377
+ }
378
+
379
+ function readConfigAtTarget(targetPath) {
380
+ inspectConfigTarget(targetPath);
381
+ try {
382
+ return JSON.parse(fs.readFileSync(targetPath, "utf8"));
383
+ } catch (error) {
384
+ if (error.code === "ENOENT") return {};
385
+ throw error;
386
+ }
387
+ }
388
+
389
+ function syncParentDirectory(directory) {
390
+ let fd = null;
391
+ try {
392
+ fd = fs.openSync(directory, "r");
393
+ try {
394
+ fs.fsyncSync(fd);
395
+ } catch (error) {
396
+ if (!isUnsupportedFsError(error)) throw error;
397
+ }
398
+ } catch (error) {
399
+ if (!isUnsupportedFsError(error)) throw error;
400
+ } finally {
401
+ if (fd !== null) {
402
+ try { fs.closeSync(fd); } catch {}
403
+ }
404
+ }
405
+ }
406
+
407
+ function writeConfigFileAtomically(config, targetPath = resolveConfigTargetPath()) {
408
+ const serialized = JSON.stringify(config, null, 2);
409
+ const configDir = path.dirname(targetPath);
410
+ const baseName = path.basename(targetPath);
411
+ const existingStat = inspectConfigTarget(targetPath);
412
+ const existingMode = existingStat ? existingStat.mode & 0o7777 : null;
413
+ let tempPath = null;
414
+ let fd = null;
415
+ try {
416
+ fs.mkdirSync(configDir, { recursive: true });
417
+ for (let attempt = 0; attempt < 10; attempt++) {
418
+ tempPath = path.join(
419
+ configDir,
420
+ `.${baseName}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.${attempt}.tmp`,
421
+ );
422
+ try {
423
+ fd = fs.openSync(tempPath, "wx", 0o600);
424
+ break;
425
+ } catch (error) {
426
+ if (error.code !== "EEXIST") throw error;
427
+ }
428
+ }
429
+ if (fd === null) throw new Error("Could not create a unique Jira config temporary file.");
430
+ if (existingMode !== null) {
431
+ try {
432
+ fs.fchmodSync(fd, existingMode);
433
+ } catch (error) {
434
+ if (!isUnsupportedFsError(error)) throw error;
435
+ }
436
+ }
437
+ fs.writeFileSync(fd, serialized, "utf8");
438
+ try {
439
+ fs.fsyncSync(fd);
440
+ } catch (error) {
441
+ if (!isUnsupportedFsError(error)) throw error;
442
+ }
443
+ fs.closeSync(fd);
444
+ fd = null;
445
+ fs.renameSync(tempPath, targetPath);
446
+ tempPath = null;
447
+ syncParentDirectory(configDir);
448
+ } finally {
449
+ if (fd !== null) {
450
+ try { fs.closeSync(fd); } catch {}
451
+ }
452
+ if (tempPath) {
453
+ try { fs.unlinkSync(tempPath); } catch {}
454
+ }
455
+ }
456
+ }
457
+
458
+ function withConfigLockSync(callback) {
459
+ const lock = acquireLockSync(resolveConfigTargetPath());
460
+ try {
461
+ return callback(lock);
462
+ } finally {
463
+ releaseClaim(lock);
464
+ }
465
+ }
466
+
467
+ function updateConfigSync(mutator) {
468
+ return withConfigLockSync(({ targetPath }) => {
469
+ const config = readConfigAtTarget(targetPath);
470
+ const result = mutator(config);
471
+ writeConfigFileAtomically(config, targetPath);
472
+ return result;
473
+ });
474
+ }
475
+
476
+ function loadConfig() {
477
+ try {
478
+ return JSON.parse(fs.readFileSync(configPath, "utf8"));
479
+ } catch {
480
+ return null;
481
+ }
482
+ }
483
+
484
+ function loadConfigStrict() {
485
+ return JSON.parse(fs.readFileSync(configPath, "utf8"));
486
+ }
487
+
488
+ function migrateLegacyConfig(config, name = "default") {
489
+ if (Array.isArray(config.instances)) return false;
490
+ if (config.instances !== undefined) {
491
+ throw new Error("Jira config has an invalid instances value.");
492
+ }
493
+
494
+ if (
495
+ config.email !== undefined ||
496
+ config.token !== undefined ||
497
+ config.baseUrl !== undefined ||
498
+ config.projects !== undefined
499
+ ) {
500
+ const legacyInstance = {
501
+ name,
502
+ email: config.email,
503
+ token: config.token,
504
+ baseUrl: config.baseUrl,
505
+ projects: normalizeProjects(config.projects),
506
+ };
507
+ for (const field of ["scopes", "dryRun", "defaultTeam", "projectTeams", "confluenceBaseUrl"]) {
508
+ if (config[field] !== undefined) legacyInstance[field] = config[field];
509
+ }
510
+ config.instances = [legacyInstance];
511
+ if (!config.defaultInstance) config.defaultInstance = name;
512
+ delete config.email;
513
+ delete config.token;
514
+ delete config.baseUrl;
515
+ delete config.projects;
516
+ return true;
517
+ }
518
+
519
+ config.instances = [];
520
+ return true;
521
+ }
522
+
523
+ function assertNoDuplicateProjectOwnership(config, instanceName, projects) {
524
+ const normalized = normalizeProjects(projects);
525
+ if (!Array.isArray(config.instances)) {
526
+ throw new Error("Jira config must use instances before project ownership is updated.");
527
+ }
528
+ for (const prefix of normalized) {
529
+ const owners = config.instances.filter(
530
+ (instance) =>
531
+ instance.name !== instanceName &&
532
+ normalizeProjects(instance.projects).includes(prefix),
533
+ );
534
+ if (owners.length > 0) {
535
+ throw new Error(
536
+ `Project prefix "${prefix}" is already mapped to Jira instance(s) ${owners.map((owner) => `"${owner.name}"`).join(", ")}.`,
537
+ );
538
+ }
539
+ }
540
+ }
541
+
542
+ module.exports = {
543
+ configPath,
544
+ loadConfig,
545
+ loadConfigStrict,
546
+ normalizeProjects,
547
+ resolveConfigTargetPath,
548
+ migrateLegacyConfig,
549
+ assertNoDuplicateProjectOwnership,
550
+ writeConfigFileAtomically,
551
+ withConfigLockSync,
552
+ updateConfigSync,
553
+ };