@falldownthesystem/devtool 0.5.0 → 0.6.1

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.
@@ -0,0 +1,263 @@
1
+ import { open, rm, lstat, mkdtemp, link } from 'node:fs/promises';
2
+ import { resolve, dirname, join } from 'node:path';
3
+ import { Readable } from 'node:stream';
4
+ import { pipeline } from 'node:stream/promises';
5
+ import { validateGraphSite } from '../config/store.js';
6
+
7
+ const BASE = 'https://graph.microsoft.com/v1.0';
8
+ const MAIL_FIELDS = 'id,subject,from,toRecipients,receivedDateTime,sentDateTime,isRead,hasAttachments,bodyPreview,webLink,parentFolderId';
9
+
10
+ export function graphUrl(path, params = {}) {
11
+ const url = new URL(path.startsWith('/') ? `${BASE}${path}` : path);
12
+ if (url.origin !== 'https://graph.microsoft.com' || !url.pathname.startsWith('/v1.0/') || url.username || url.password || url.hash) {
13
+ throw new Error('Refused an invalid Microsoft Graph URL.');
14
+ }
15
+ for (const [key, value] of Object.entries(params)) {
16
+ if (value !== undefined && value !== '') url.searchParams.set(key, String(value));
17
+ }
18
+ return url;
19
+ }
20
+
21
+ export function graphId(value) {
22
+ if (typeof value !== 'string' || !value.trim() || value === '.' || value === '..') throw new Error('A resource ID is required.');
23
+ return encodeURIComponent(value);
24
+ }
25
+
26
+ export function graphLimit(value = 25) {
27
+ if (!/^\d+$/.test(String(value)) || Number(value) < 1 || Number(value) > 1000) {
28
+ throw new Error('Limit must be an integer from 1 to 1000.');
29
+ }
30
+ return Number(value);
31
+ }
32
+
33
+ export function siteAddress(site) {
34
+ if (!site) throw new Error('Set defaultSite on the Microsoft Graph connection or pass --site <site-url-or-id>.');
35
+ if (!/^https?:/i.test(site)) return `/sites/${graphId(site)}`;
36
+ const invalid = validateGraphSite(site);
37
+ if (invalid) throw new Error(invalid);
38
+ const url = new URL(site);
39
+ const path = url.pathname.split('/').filter(Boolean).map((part) => graphId(decodeURIComponent(part))).join('/');
40
+ return path ? `/sites/${url.hostname}:/${path}` : `/sites/${url.hostname}`;
41
+ }
42
+
43
+ function mailboxPath(mailbox) {
44
+ return mailbox ? `/users/${graphId(mailbox)}` : '/me';
45
+ }
46
+
47
+ export function calendarRange({ from, to } = {}, now = new Date()) {
48
+ function parse(value) {
49
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/i.test(value) || !Number.isFinite(Date.parse(value))) {
50
+ throw new Error('Calendar dates must use ISO 8601 with a timezone, for example 2026-09-14T09:00:00+03:00.');
51
+ }
52
+ return new Date(value);
53
+ }
54
+ const start = from ? parse(from) : now;
55
+ const end = to ? parse(to) : new Date(start.getTime() + 30 * 86400000);
56
+ if (end <= start || end - start > 366 * 86400000) throw new Error('Calendar end must follow start, within 366 days.');
57
+ return { startDateTime: start.toISOString(), endDateTime: end.toISOString() };
58
+ }
59
+
60
+ function calendarPath({ mailbox, calendar } = {}) {
61
+ return `${mailboxPath(mailbox)}/${calendar ? `calendars/${graphId(calendar)}` : 'calendar'}`;
62
+ }
63
+
64
+ function eventStart(event) {
65
+ const value = event.start?.dateTime || '';
66
+ const start = Date.parse(/(?:[zZ]|[+-]\d{2}:\d{2})$/.test(value) ? value : `${value}Z`);
67
+ if (!Number.isFinite(start)) throw new Error('Microsoft Graph returned an event without a valid start time.');
68
+ return start;
69
+ }
70
+
71
+ export function createGraphClient({ auth, defaultSite, fetchImpl = fetch }) {
72
+ async function response(path, params = {}, { sharedMail = false, calendars = false, availability = false, headers = {}, scheduleQuery, timeout = 60000 } = {}) {
73
+ const url = graphUrl(path, params);
74
+ if (scheduleQuery && (url.pathname !== '/v1.0/me/calendar/getSchedule' || url.search)) throw new Error('Only the read-only getSchedule query can use POST.');
75
+ const token = await auth.token({ sharedMail, calendars, availability });
76
+ const result = await fetchImpl(url, {
77
+ method: scheduleQuery ? 'POST' : 'GET',
78
+ headers: { ...headers, Accept: 'application/json', Authorization: `Bearer ${token}`, ...(scheduleQuery ? { 'Content-Type': 'application/json' } : {}) },
79
+ ...(scheduleQuery ? { body: JSON.stringify(scheduleQuery) } : {}),
80
+ redirect: 'manual',
81
+ signal: AbortSignal.timeout(timeout),
82
+ });
83
+ return result;
84
+ }
85
+
86
+ async function check(result) {
87
+ if (result.ok) return;
88
+ let message = `Microsoft Graph request failed (${result.status}).`;
89
+ if (result.status === 401) message = 'Microsoft Graph rejected the sign-in. Run: dev graph login';
90
+ else if (result.status === 403) message = 'Microsoft Graph access denied. Check delegated permissions, administrator consent, and your access to this resource. Shared mail also needs Mail.Read.Shared and Exchange access.';
91
+ else if (result.status === 404) message = 'Microsoft Graph resource not found. Check its ID and your access.';
92
+ else if (result.status === 429) message = `Microsoft Graph rate limit reached. Retry after ${result.headers.get('retry-after') || 'a few'} seconds.`;
93
+ else if (result.status >= 300 && result.status < 400) message = 'Refused an unexpected Microsoft Graph redirect.';
94
+ if (result.headers.get('content-type')?.includes('application/json')) {
95
+ try {
96
+ const body = await result.json();
97
+ const code = body.error?.code;
98
+ if (typeof code === 'string' && /^[a-z0-9_.-]{1,80}$/i.test(code)) message += ` Code: ${code}.`;
99
+ } catch {}
100
+ } else {
101
+ await result.body?.cancel();
102
+ }
103
+ const err = new Error(message);
104
+ err.status = result.status;
105
+ throw err;
106
+ }
107
+
108
+ async function request(path, params, options) {
109
+ const result = await response(path, params, options);
110
+ await check(result);
111
+ return result.json();
112
+ }
113
+
114
+ async function collection(path, params = {}, { limit = 25, pageSize = 100, requireComplete = false, ...options } = {}) {
115
+ limit = graphLimit(limit);
116
+ let next = graphUrl(path, { ...params, $top: Math.min(limit, pageSize) }).href;
117
+ const seen = new Set();
118
+ const items = [];
119
+ while (next && items.length < limit) {
120
+ if (seen.has(next) || seen.size >= 1000) throw new Error('Microsoft Graph returned invalid pagination.');
121
+ seen.add(next);
122
+ const page = await request(next, {}, options);
123
+ if (!Array.isArray(page.value)) throw new Error('Microsoft Graph returned an invalid collection.');
124
+ const overflow = page.value.length > limit - items.length;
125
+ items.push(...page.value.slice(0, limit - items.length));
126
+ next = page['@odata.nextLink'];
127
+ if (requireComplete && (overflow || (next && items.length >= limit))) throw new Error('Calendar range exceeds 1000 events. Narrow --from and --to.');
128
+ }
129
+ return items;
130
+ }
131
+
132
+ async function siteId(site) {
133
+ const target = site || defaultSite;
134
+ if (target && !/^https?:/i.test(target)) return graphId(target);
135
+ const data = await request(siteAddress(target), { $select: 'id' });
136
+ return graphId(data.id);
137
+ }
138
+
139
+ async function calendarEvents(options = {}) {
140
+ const range = calendarRange(options);
141
+ // Read the entire bounded window before sorting, so page order cannot hide an earlier occurrence.
142
+ const events = await collection(`${calendarPath(options)}/calendarView`, range, {
143
+ limit: 1000, pageSize: 1000, requireComplete: true, calendars: true, headers: { Prefer: 'outlook.timezone="UTC"' },
144
+ });
145
+ return { range, events: events.sort((a, b) => eventStart(a) - eventStart(b)) };
146
+ }
147
+
148
+ return {
149
+ async availability(schedules, options = {}) {
150
+ if (!Array.isArray(schedules) || !schedules.length || schedules.length > 20 || schedules.some((address) => !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address))) {
151
+ throw new Error('Supply 1–20 email addresses for the availability query.');
152
+ }
153
+ const range = calendarRange(options);
154
+ if (!options.to) range.endDateTime = new Date(Date.parse(range.startDateTime) + 86400000).toISOString();
155
+ if (Date.parse(range.endDateTime) - Date.parse(range.startDateTime) >= 62 * 86400000) throw new Error('Availability range must be shorter than 62 days.');
156
+ const interval = Number(options.interval ?? 30);
157
+ if (!Number.isInteger(interval) || interval < 5 || interval > 1440) throw new Error('Availability interval must be 5–1440 minutes.');
158
+ const result = await request('/me/calendar/getSchedule', {}, {
159
+ availability: true,
160
+ headers: { Prefer: 'outlook.timezone="UTC"' },
161
+ scheduleQuery: {
162
+ schedules,
163
+ startTime: { dateTime: range.startDateTime.replace(/Z$/, ''), timeZone: 'UTC' },
164
+ endTime: { dateTime: range.endDateTime.replace(/Z$/, ''), timeZone: 'UTC' },
165
+ availabilityViewInterval: interval,
166
+ },
167
+ });
168
+ if (!Array.isArray(result.value)) throw new Error('Microsoft Graph returned an invalid availability result.');
169
+ return { from: range.startDateTime, to: range.endDateTime, intervalMinutes: interval, schedules: result.value };
170
+ },
171
+ calendars: ({ mailbox, limit } = {}) => collection(`${mailboxPath(mailbox)}/calendars`, {}, { limit, calendars: true }),
172
+ calendarEvents: async (options = {}) => {
173
+ const limit = graphLimit(options.limit);
174
+ return (await calendarEvents(options)).events.slice(0, limit);
175
+ },
176
+ nextEvent: async (options = {}) => {
177
+ const { range, events } = await calendarEvents(options);
178
+ const start = Date.parse(range.startDateTime);
179
+ const event = events.find((item) => !item.isCancelled && item.responseStatus?.response !== 'declined' && eventStart(item) >= start) || null;
180
+ return { from: range.startDateTime, to: range.endDateTime, event };
181
+ },
182
+ calendarEvent: (id, options = {}) => request(`${calendarPath(options)}/events/${graphId(id)}`, {}, { calendars: true, headers: { Prefer: 'outlook.timezone="UTC"' } }),
183
+
184
+ chats: ({ limit } = {}) => collection('/me/chats', { $expand: 'members', $orderby: 'lastMessagePreview/createdDateTime desc' }, { limit, pageSize: 50 }),
185
+ chat: (id) => request(`/chats/${graphId(id)}`, { $expand: 'members' }),
186
+ chatMessages: (id, { limit } = {}) => collection(`/chats/${graphId(id)}/messages`, { $orderby: 'createdDateTime desc' }, { limit, pageSize: 50 }),
187
+ chatMessage: (chatId, messageId) => request(`/chats/${graphId(chatId)}/messages/${graphId(messageId)}`),
188
+
189
+ mailFolders: ({ mailbox, parent, limit } = {}) => collection(`${mailboxPath(mailbox)}/mailFolders${parent ? `/${graphId(parent)}/childFolders` : ''}`, {}, { limit, sharedMail: !!mailbox }),
190
+ mailMessages: ({ mailbox, folder, search, limit } = {}) => {
191
+ const path = `${mailboxPath(mailbox)}${folder ? `/mailFolders/${graphId(folder)}` : ''}/messages`;
192
+ return collection(path, {
193
+ $select: MAIL_FIELDS,
194
+ $search: search ? JSON.stringify(search) : undefined,
195
+ $orderby: search ? undefined : 'receivedDateTime desc',
196
+ }, { limit, sharedMail: !!mailbox });
197
+ },
198
+ mailMessage: (id, { mailbox, folder } = {}) => request(`${mailboxPath(mailbox)}${folder ? `/mailFolders/${graphId(folder)}` : ''}/messages/${graphId(id)}`, {
199
+ $select: `${MAIL_FIELDS},body,ccRecipients`,
200
+ }, { sharedMail: !!mailbox, headers: { Prefer: 'outlook.body-content-type="text"' } }),
201
+
202
+ sites: (query, { limit } = {}) => collection('/sites', { search: query, $select: 'id,name,displayName,description,webUrl' }, { limit }),
203
+ site: (site) => request(siteAddress(site || defaultSite), { $select: 'id,name,displayName,description,webUrl' }),
204
+ drives: async ({ site, limit } = {}) => collection(`/sites/${await siteId(site)}/drives`, {}, { limit }),
205
+ files: (drive, { folder, path, limit } = {}) => {
206
+ if (folder && path) throw new Error('Use either --folder or --path, not both.');
207
+ let location = folder ? `items/${graphId(folder)}` : 'root';
208
+ if (path) location = `root:/${path.replace(/\\/g, '/').split('/').filter(Boolean).map(graphId).join('/')}:`;
209
+ return collection(`/drives/${graphId(drive)}/${location}/children`, {}, { limit });
210
+ },
211
+ file: (drive, id) => request(`/drives/${graphId(drive)}/items/${graphId(id)}`),
212
+ lists: async ({ site, limit } = {}) => collection(`/sites/${await siteId(site)}/lists`, {}, { limit }),
213
+ listItems: async (list, { site, limit } = {}) => collection(`/sites/${await siteId(site)}/lists/${graphId(list)}/items`, { $expand: 'fields' }, { limit }),
214
+ listItem: async (list, item, { site } = {}) => request(`/sites/${await siteId(site)}/lists/${graphId(list)}/items/${graphId(item)}`, { $expand: 'fields' }),
215
+
216
+ async download(drive, id, destination, { timeout = 120 } = {}) {
217
+ if (!/^\d+$/.test(String(timeout)) || Number(timeout) < 1 || Number(timeout) > 86400) throw new Error('Download timeout must be 1–86400 seconds.');
218
+ const milliseconds = Number(timeout) * 1000;
219
+ try {
220
+ await lstat(destination);
221
+ throw Object.assign(new Error('Destination already exists. Choose a new local path.'), { code: 'EEXIST' });
222
+ } catch (err) {
223
+ if (err.code !== 'ENOENT') throw err;
224
+ }
225
+ const directory = await mkdtemp(join(dirname(resolve(destination)), '.devtool-download-'));
226
+ const temporary = join(directory, 'content');
227
+ let failure;
228
+ try {
229
+ let result = await response(`/drives/${graphId(drive)}/items/${graphId(id)}/content`, {}, { timeout: milliseconds });
230
+ if (result.status === 302) {
231
+ const target = new URL(result.headers.get('location'));
232
+ await result.body?.cancel();
233
+ if (target.protocol !== 'https:' || !target.hostname.endsWith('.sharepoint.com') || target.username || target.password || target.port) {
234
+ throw new Error('Refused a download redirect outside SharePoint Online.');
235
+ }
236
+ // The download URL already authorizes this request; never forward the Graph bearer token.
237
+ result = await fetchImpl(target, { method: 'GET', redirect: 'error', signal: AbortSignal.timeout(milliseconds) });
238
+ }
239
+ await check(result);
240
+ if (!result.body) throw new Error('SharePoint returned no file content.');
241
+ const file = await open(temporary, 'wx', 0o600);
242
+ try {
243
+ await pipeline(Readable.fromWeb(result.body), file.createWriteStream());
244
+ } finally {
245
+ await file.close();
246
+ }
247
+ // Publish only a complete file. An atomic hard link refuses a concurrently created destination.
248
+ await link(temporary, destination);
249
+ return { path: resolve(destination), downloaded: true };
250
+ } catch (err) {
251
+ failure = err;
252
+ throw err;
253
+ } finally {
254
+ try {
255
+ await rm(directory, { recursive: true, force: true });
256
+ } catch (err) {
257
+ if (failure) failure.message += ' Temporary download cleanup failed.';
258
+ else throw new Error('The download is complete, but temporary file cleanup failed.', { cause: err });
259
+ }
260
+ }
261
+ },
262
+ };
263
+ }
@@ -1,13 +1,12 @@
1
1
  import { Version2Client } from 'jira.js';
2
- import { get } from '../config/store.js';
3
2
 
4
- let client = null;
3
+ const clients = new Map();
5
4
 
6
- function getClient() {
7
- if (!client) {
8
- const token = get('jira.token');
9
- client = new Version2Client({
10
- host: get('jira.baseUrl'),
5
+ function getClient({ baseUrl, token }) {
6
+ const key = `${baseUrl}\n${token}`;
7
+ if (!clients.has(key)) {
8
+ const client = new Version2Client({
9
+ host: baseUrl,
11
10
  authentication: {
12
11
  basic: { email: '', apiToken: token },
13
12
  },
@@ -16,20 +15,21 @@ function getClient() {
16
15
  config.headers['Authorization'] = `Bearer ${token}`;
17
16
  return config;
18
17
  });
18
+ clients.set(key, client);
19
19
  }
20
- return client;
20
+ return clients.get(key);
21
21
  }
22
22
 
23
- export async function getIssue(issueKey) {
24
- return getClient().issues.getIssue({
23
+ export async function getIssue(connection, issueKey) {
24
+ return getClient(connection).issues.getIssue({
25
25
  issueIdOrKey: issueKey,
26
26
  fields: ['*all'],
27
27
  expand: 'renderedFields',
28
28
  });
29
29
  }
30
30
 
31
- export async function getIssueComments(issueKey) {
32
- const result = await getClient().issueComments.getComments({
31
+ export async function getIssueComments(connection, issueKey) {
32
+ const result = await getClient(connection).issueComments.getComments({
33
33
  issueIdOrKey: issueKey,
34
34
  orderBy: 'created',
35
35
  maxResults: 100,
@@ -37,8 +37,8 @@ export async function getIssueComments(issueKey) {
37
37
  return result.comments || [];
38
38
  }
39
39
 
40
- export async function searchIssues(jql, startAt = 0, maxResults = 50) {
41
- const result = await getClient().issueSearch.searchForIssuesUsingJql({
40
+ export async function searchIssues(connection, jql, startAt = 0, maxResults = 50) {
41
+ const result = await getClient(connection).issueSearch.searchForIssuesUsingJql({
42
42
  jql,
43
43
  startAt,
44
44
  maxResults,
@@ -52,21 +52,21 @@ export async function searchIssues(jql, startAt = 0, maxResults = 50) {
52
52
  };
53
53
  }
54
54
 
55
- export async function getMyIssues() {
56
- const project = get('jira.defaultProject');
55
+ export async function getMyIssues(connection) {
56
+ const project = connection.defaultProject;
57
57
  const jql = project
58
58
  ? `project = ${project} AND assignee = currentUser() AND status != Done ORDER BY updated DESC`
59
59
  : `assignee = currentUser() AND status != Done ORDER BY updated DESC`;
60
- return searchIssues(jql);
60
+ return searchIssues(connection, jql);
61
61
  }
62
62
 
63
- export async function getIssueChildren(issueKey) {
64
- const issue = await getIssue(issueKey);
63
+ export async function getIssueChildren(connection, issueKey) {
64
+ const issue = await getIssue(connection, issueKey);
65
65
  const type = issue.fields.issuetype?.name;
66
66
  const parent = { key: issue.key, type, summary: issue.fields.summary };
67
67
 
68
68
  if (type === 'Epic') {
69
- const response = await getClient().instance.get(
69
+ const response = await getClient(connection).instance.get(
70
70
  `/rest/agile/1.0/epic/${encodeURIComponent(issueKey)}/issue`,
71
71
  {
72
72
  params: {
@@ -1,13 +1,11 @@
1
- import { get } from '../config/store.js';
2
-
3
1
  function configurationError(reason) {
4
- return new Error(`Service Desk configuration error: ${reason}. Check serviceDesk.baseUrl; use the canonical https://<site>.atlassian.net URL.`);
2
+ return new Error(`Service Desk configuration error: ${reason}. Check the base URL of the Service Desk connection; use the canonical https://<site>.atlassian.net URL.`);
5
3
  }
6
4
 
7
- function baseUrl() {
5
+ function baseUrl(connection) {
8
6
  let url;
9
7
  try {
10
- url = new URL(get('serviceDesk.baseUrl'));
8
+ url = new URL(connection.baseUrl);
11
9
  } catch {
12
10
  throw configurationError('invalid base URL');
13
11
  }
@@ -17,9 +15,7 @@ function baseUrl() {
17
15
  return new URL(url.origin);
18
16
  }
19
17
 
20
- function authHeader() {
21
- const email = get('serviceDesk.email');
22
- const token = get('serviceDesk.token');
18
+ function authHeader({ email = '', token = '' }) {
23
19
  return 'Basic ' + Buffer.from(`${email}:${token}`).toString('base64');
24
20
  }
25
21
 
@@ -58,8 +54,8 @@ async function fetchJson(url, headers = {}) {
58
54
  return data;
59
55
  }
60
56
 
61
- async function request(path, params = {}, { extraHeaders = {}, validate = () => true } = {}) {
62
- const site = baseUrl();
57
+ async function request(connection, path, params = {}, { extraHeaders = {}, validate = () => true } = {}) {
58
+ const site = baseUrl(connection);
63
59
  if (!site.hostname.endsWith('.atlassian.net')) {
64
60
  const info = await fetchJson(new URL('/rest/api/2/serverInfo', site));
65
61
  let canonical;
@@ -77,21 +73,21 @@ async function request(path, params = {}, { extraHeaders = {}, validate = () =>
77
73
  for (const [k, v] of Object.entries(params)) {
78
74
  if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, v);
79
75
  }
80
- const data = await fetchJson(url, { 'Authorization': authHeader(), ...extraHeaders });
76
+ const data = await fetchJson(url, { 'Authorization': authHeader(connection), ...extraHeaders });
81
77
  if (!validate(data)) throw configurationError('unexpected API response structure');
82
78
  return data;
83
79
  }
84
80
 
85
- export async function getIssue(issueKey) {
86
- return request(`/rest/api/3/issue/${encodeURIComponent(issueKey)}`, {
81
+ export async function getIssue(connection, issueKey) {
82
+ return request(connection, `/rest/api/3/issue/${encodeURIComponent(issueKey)}`, {
87
83
  expand: 'renderedFields',
88
84
  }, {
89
85
  validate: (data) => typeof data.key === 'string' && data.fields && typeof data.fields === 'object' && !Array.isArray(data.fields),
90
86
  });
91
87
  }
92
88
 
93
- export async function getIssueComments(issueKey) {
94
- const data = await request(`/rest/api/3/issue/${encodeURIComponent(issueKey)}/comment`, {
89
+ export async function getIssueComments(connection, issueKey) {
90
+ const data = await request(connection, `/rest/api/3/issue/${encodeURIComponent(issueKey)}/comment`, {
95
91
  orderBy: 'created',
96
92
  maxResults: 100,
97
93
  }, {
@@ -100,9 +96,9 @@ export async function getIssueComments(issueKey) {
100
96
  return data.comments;
101
97
  }
102
98
 
103
- export async function searchIssues(jql, maxResults = 50, nextPageToken = '') {
99
+ export async function searchIssues(connection, jql, maxResults = 50, nextPageToken = '') {
104
100
  const fields = 'summary,status,assignee,priority,updated,issuetype,reporter,customfield_10010';
105
- const data = await request('/rest/api/3/search/jql', {
101
+ const data = await request(connection, '/rest/api/3/search/jql', {
106
102
  jql,
107
103
  fields,
108
104
  maxResults,
@@ -120,21 +116,21 @@ export async function searchIssues(jql, maxResults = 50, nextPageToken = '') {
120
116
  };
121
117
  }
122
118
 
123
- export async function getMyIssues() {
124
- const project = get('serviceDesk.defaultProject');
119
+ export async function getMyIssues(connection) {
120
+ const project = connection.defaultProject;
125
121
  const jql = project
126
122
  ? `project = ${project} AND assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC`
127
123
  : `assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC`;
128
- return searchIssues(jql);
124
+ return searchIssues(connection, jql);
129
125
  }
130
126
 
131
- let _serviceDeskIdCache = null;
127
+ // Keyed by site as well as project, because two sites can both have a project with the same key.
128
+ const serviceDeskIds = new Map();
132
129
 
133
- async function getServiceDeskId(projectKey) {
134
- if (_serviceDeskIdCache && _serviceDeskIdCache.projectKey === projectKey) {
135
- return _serviceDeskIdCache.id;
136
- }
137
- const data = await request('/rest/servicedeskapi/servicedesk', { start: 0, limit: 100 }, {
130
+ async function getServiceDeskId(connection, projectKey) {
131
+ const key = `${baseUrl(connection).origin} ${projectKey}`;
132
+ if (serviceDeskIds.has(key)) return serviceDeskIds.get(key);
133
+ const data = await request(connection, '/rest/servicedeskapi/servicedesk', { start: 0, limit: 100 }, {
138
134
  extraHeaders: { 'X-ExperimentalApi': 'opt-in' },
139
135
  validate: (data) => Array.isArray(data.values),
140
136
  });
@@ -142,23 +138,23 @@ async function getServiceDeskId(projectKey) {
142
138
  if (!match) {
143
139
  throw new Error(`No Service Desk found for project ${projectKey}`);
144
140
  }
145
- _serviceDeskIdCache = { projectKey, id: match.id };
141
+ serviceDeskIds.set(key, match.id);
146
142
  return match.id;
147
143
  }
148
144
 
149
- export async function listQueues() {
150
- const projectKey = get('serviceDesk.defaultProject');
151
- if (!projectKey) throw new Error('serviceDesk.defaultProject is not configured');
152
- const sdId = await getServiceDeskId(projectKey);
153
- const data = await request(`/rest/servicedeskapi/servicedesk/${sdId}/queue`, { start: 0, limit: 100 }, {
145
+ export async function listQueues(connection) {
146
+ const projectKey = connection.defaultProject;
147
+ if (!projectKey) throw new Error('No default project is set on the Service Desk connection');
148
+ const sdId = await getServiceDeskId(connection, projectKey);
149
+ const data = await request(connection, `/rest/servicedeskapi/servicedesk/${sdId}/queue`, { start: 0, limit: 100 }, {
154
150
  extraHeaders: { 'X-ExperimentalApi': 'opt-in' },
155
151
  validate: (data) => Array.isArray(data.values),
156
152
  });
157
153
  return data.values.map((q) => ({ id: q.id, name: q.name, jql: q.jql }));
158
154
  }
159
155
 
160
- export async function findQueue(nameOrId) {
161
- const queues = await listQueues();
156
+ export async function findQueue(connection, nameOrId) {
157
+ const queues = await listQueues(connection);
162
158
  const idMatch = queues.find((q) => String(q.id) === String(nameOrId));
163
159
  if (idMatch) return idMatch;
164
160
  const lower = nameOrId.toLowerCase();
@@ -174,8 +170,8 @@ export async function findQueue(nameOrId) {
174
170
  throw new Error(`No queue found matching "${nameOrId}"`);
175
171
  }
176
172
 
177
- export async function searchByQueue(nameOrId, maxResults = 50, nextPageToken = '') {
178
- const queue = await findQueue(nameOrId);
179
- const result = await searchIssues(queue.jql, maxResults, nextPageToken);
173
+ export async function searchByQueue(connection, nameOrId, maxResults = 50, nextPageToken = '') {
174
+ const queue = await findQueue(connection, nameOrId);
175
+ const result = await searchIssues(connection, queue.jql, maxResults, nextPageToken);
180
176
  return { ...result, queue };
181
177
  }
@@ -1,31 +1,80 @@
1
1
  import { Command } from 'commander';
2
- import { getAll, set, runSetup, configPath, schema } from '../config/store.js';
2
+ import {
3
+ types, label, listConnections, removeConnection, setDefault, setField, configPath,
4
+ } from '../config/store.js';
5
+ import { runSetup, addConnection, editConnection, describe } from '../config/setup.js';
3
6
  import { heading, field, mask, success, error, divider } from '../utils/output.js';
4
7
 
8
+ function attempt(action) {
9
+ return async (...args) => {
10
+ try {
11
+ await action(...args);
12
+ } catch (err) {
13
+ error(err.message);
14
+ process.exitCode = 1;
15
+ }
16
+ };
17
+ }
18
+
19
+ function parseType(input) {
20
+ if (input === undefined) return undefined;
21
+ const type = Object.keys(types).find((t) => t.toLowerCase() === input.toLowerCase());
22
+ if (!type) throw new Error(`Unknown connection type "${input}". Valid types: ${Object.keys(types).join(', ')}`);
23
+ return type;
24
+ }
25
+
5
26
  const config = new Command('config')
6
- .description('Manage configuration');
27
+ .description('Manage connections');
7
28
 
8
29
  config
9
30
  .command('setup')
10
- .description('Run interactive setup')
11
- .action(async () => {
12
- await runSetup();
13
- });
31
+ .description('Add or edit connections interactively')
32
+ .action(attempt(runSetup));
33
+
34
+ config
35
+ .command('add [type]')
36
+ .description(`Add a connection (${Object.keys(types).join(', ')})`)
37
+ .option('-n, --name <name>', 'Connection name')
38
+ .action(attempt((type, opts) => addConnection(parseType(type), opts.name)));
39
+
40
+ config
41
+ .command('edit <connection>')
42
+ .description('Edit a connection interactively')
43
+ .action(attempt(editConnection));
44
+
45
+ config
46
+ .command('remove <connection>')
47
+ .alias('rm')
48
+ .description('Remove a connection')
49
+ .action(attempt((ref) => {
50
+ success(`Removed connection "${label(removeConnection(ref))}"`);
51
+ }));
52
+
53
+ config
54
+ .command('default <connection>')
55
+ .description('Use a connection when no --connection is given for its type')
56
+ .action(attempt((ref) => {
57
+ const connection = setDefault(ref);
58
+ success(`"${label(connection)}" is now the default ${types[connection.type].title} connection`);
59
+ }));
14
60
 
15
61
  config
16
62
  .command('show')
17
- .alias('get')
18
- .description('Show current configuration')
63
+ .aliases(['get', 'list'])
64
+ .description('Show all connections')
19
65
  .action(() => {
20
- const all = getAll();
66
+ const connections = listConnections();
67
+ if (!connections.length) {
68
+ console.log('\nNo connections configured. Run `dev config add` to add one.');
69
+ }
21
70
 
22
- for (const [section, definition] of Object.entries(schema)) {
23
- heading(definition.title);
24
- for (const [key, property] of Object.entries(definition.properties)) {
25
- const value = all[section]?.[key] ?? '';
26
- field(property.title, property.writeOnly ? mask(value) : value);
71
+ connections.forEach((connection, index) => {
72
+ heading(describe(connections, index));
73
+ for (const [key, property] of Object.entries(types[connection.type].fields)) {
74
+ const value = connection[key] ?? '';
75
+ field(property.title, property.secret && value ? mask(value) : value);
27
76
  }
28
- }
77
+ });
29
78
 
30
79
  console.log('');
31
80
  divider();
@@ -35,17 +84,12 @@ config
35
84
 
36
85
  config
37
86
  .command('set <key> <value>')
38
- .description('Set a config value (e.g. bitbucket.workspace my-ws)')
39
- .action((key, value) => {
40
- const validKeys = Object.entries(schema).flatMap(([section, definition]) =>
41
- Object.keys(definition.properties).map((key) => `${section}.${key}`));
42
- if (!validKeys.includes(key)) {
43
- error(`Invalid key: ${key}`);
44
- console.log(`Valid keys: ${validKeys.join(', ')}`);
45
- return;
46
- }
47
- set(key, value);
48
- success(`Set ${key}`);
49
- });
87
+ .description('Set a connection field, as <connection>.<field> (e.g. bitbucket.workspace my-ws)')
88
+ .action(attempt((key, value) => {
89
+ const dot = key.lastIndexOf('.');
90
+ if (dot <= 0) throw new Error(`Invalid key: ${key}. Use <connection>.<field>, e.g. jira.baseUrl`);
91
+ const connection = setField(key.slice(0, dot), key.slice(dot + 1), value);
92
+ success(`Set ${key.slice(dot + 1)} on connection "${label(connection)}"`);
93
+ }));
50
94
 
51
95
  export default config;