@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.
@@ -13,7 +13,7 @@ import {
13
13
  import { spinner } from '../utils/output.js';
14
14
  import { handleError } from '../utils/errors.js';
15
15
  import { getOutputMode, outputJson } from '../utils/mode.js';
16
- import { get } from '../config/store.js';
16
+ import { connectionFor } from '../utils/connection.js';
17
17
 
18
18
  const confluence = new Command('confluence')
19
19
  .description('Confluence pages');
@@ -24,7 +24,7 @@ confluence.command('spaces')
24
24
  const mode = getOutputMode(this);
25
25
  const s = mode === 'rich' ? spinner('Fetching spaces...').start() : null;
26
26
  try {
27
- const spaces = await listSpaces();
27
+ const spaces = await listSpaces(connectionFor(this, 'confluence'));
28
28
  if (s) s.success({ text: `${spaces.length} space(s) found` });
29
29
  if (mode === 'json') outputJson(curateSpacesJson(spaces));
30
30
  else if (mode === 'plain') plainSpaces(spaces);
@@ -42,7 +42,7 @@ confluence.command('view <id>')
42
42
  const mode = getOutputMode(this);
43
43
  const s = mode === 'rich' ? spinner(`Fetching page ${id}...`).start() : null;
44
44
  try {
45
- const page = await getPage(id);
45
+ const page = await getPage(connectionFor(this, 'confluence'), id);
46
46
  if (s) s.success({ text: page.title || `Page ${id}` });
47
47
  if (mode === 'json') outputJson(curatePageJson(page));
48
48
  else if (mode === 'plain') plainPageView(page);
@@ -62,15 +62,16 @@ confluence.command('pages')
62
62
  const mode = getOutputMode(this);
63
63
  const s = mode === 'rich' ? spinner('Fetching pages...').start() : null;
64
64
  try {
65
+ const connection = connectionFor(this, 'confluence');
65
66
  const spaceId = opts.spaceId;
66
- const spaceKey = spaceId ? undefined : opts.space || get('confluence.defaultSpace');
67
+ const spaceKey = spaceId ? undefined : opts.space || connection.defaultSpace;
67
68
  if (!spaceId && !spaceKey) {
68
69
  if (s) s.error({ text: 'No space specified' });
69
- console.error('Specify --space <key>, --space-id <id>, or set confluence.defaultSpace');
70
+ console.error('Specify --space <key>, --space-id <id>, or set defaultSpace on the Confluence connection');
70
71
  process.exitCode = 1;
71
72
  return;
72
73
  }
73
- const pages = await listSpacePages({ spaceId, spaceKey });
74
+ const pages = await listSpacePages(connection, { spaceId, spaceKey });
74
75
  if (!pages) {
75
76
  if (s) s.error({ text: `Space "${spaceId || spaceKey}" not found` });
76
77
  process.exitCode = 1;
@@ -93,7 +94,7 @@ confluence.command('search <cql>')
93
94
  const mode = getOutputMode(this);
94
95
  const s = mode === 'rich' ? spinner('Searching...').start() : null;
95
96
  try {
96
- const result = await searchPages(cql);
97
+ const result = await searchPages(connectionFor(this, 'confluence'), cql);
97
98
  const count = result.totalSize || (result.results || []).length;
98
99
  if (s) s.success({ text: `${count} result(s)` });
99
100
  if (mode === 'json') outputJson(curateSearchJson(result));
@@ -0,0 +1,147 @@
1
+ import { Command, InvalidArgumentError } from 'commander';
2
+ import { createGraphClient, graphLimit } from '../clients/graph.js';
3
+ import { createGraphAuth } from '../clients/graph-auth.js';
4
+ import { label, listConnections, getConnection } from '../config/store.js';
5
+ import { addConnection, editConnection } from '../config/setup.js';
6
+ import { getOutputMode } from '../utils/mode.js';
7
+ import { connectionFor } from '../utils/connection.js';
8
+ import { spinner } from '../utils/output.js';
9
+ import {
10
+ formatGraph, graphText, curateChatJson, curateChatMessageJson, curateMailJson,
11
+ curateFolderJson, curateSiteJson, curateDriveJson, curateFileJson, curateListJson, curateListItemJson,
12
+ curateCalendarJson, curateEventJson, curateNextEventJson, curateAvailabilityJson, formatNextEvent, formatAvailability,
13
+ } from '../formatters/graph.js';
14
+
15
+ function limit(value) {
16
+ try { return graphLimit(value); }
17
+ catch (err) { throw new InvalidArgumentError(err.message); }
18
+ }
19
+
20
+ export function graphErrorMessage(err) {
21
+ const code = err.cause?.code || err.code;
22
+ if (err.name === 'TimeoutError' || ['ETIMEDOUT', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT'].includes(code)) return 'Microsoft Graph request timed out. Check your connection; for a slow file download, increase --timeout.';
23
+ if (['ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'ECONNRESET', 'ENETUNREACH', 'EHOSTUNREACH'].includes(code)) return `Cannot reach Microsoft Graph (${code}). Check your network, VPN, and proxy settings.`;
24
+ if (['UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'SELF_SIGNED_CERT_IN_CHAIN', 'CERT_HAS_EXPIRED', 'DEPTH_ZERO_SELF_SIGNED_CERT', 'ERR_TLS_CERT_ALTNAME_INVALID'].includes(code)) return 'Microsoft Graph TLS certificate validation failed. Check the certificate chain and your proxy configuration.';
25
+ if (err.cause?.message === 'unexpected redirect') return 'Refused an additional SharePoint download redirect.';
26
+ if (err.cause && code && /^[a-z0-9_.-]{1,80}$/i.test(code)) return `Microsoft Graph connection failed (${code}). Check your network and proxy settings.`;
27
+ return graphText(err.message || 'Microsoft Graph request failed.');
28
+ }
29
+
30
+ function connectGraph(cmd) {
31
+ const connection = connectionFor(cmd, 'graph');
32
+ const auth = createGraphAuth({ connection });
33
+ return { auth, client: createGraphClient({ auth, defaultSite: connection.defaultSite }) };
34
+ }
35
+
36
+ // Edits the selected Graph connection, or adds one when none exists yet.
37
+ async function setupGraph(ref) {
38
+ if (!ref && !listConnections().some((c) => c.type === 'graph')) return addConnection('graph');
39
+ return editConnection(label(getConnection('graph', ref)));
40
+ }
41
+
42
+ export function createGraphCommand({ connect = connectGraph, setup = setupGraph } = {}) {
43
+ const graph = new Command('graph').description('Read Microsoft Graph: Teams, email, calendars, and SharePoint')
44
+ .configureOutput({ writeErr: (text) => {
45
+ const message = graphText(text).trim();
46
+ process.stderr.write(getOutputMode(graph) === 'json' ? `${JSON.stringify({ error: { message, status: null } })}\n` : `${message}\n`);
47
+ } });
48
+ const calendar = graph.command('calendar').alias('calendars').description('Read your calendars and accessible shared calendars')
49
+ .option('--mailbox <address>', 'Calendar owner address or user ID; requires existing calendar access')
50
+ .option('--calendar <id>', 'Calendar ID from list; defaults to the primary calendar');
51
+ const teams = graph.command('teams').description('Read your Teams chats (not channel messages)');
52
+ const mail = graph.command('mail').alias('email').description('Read email and folders')
53
+ .option('--mailbox <address>', 'Shared mailbox or folder owner; requires Mail.Read.Shared and Exchange access');
54
+ const sharepoint = graph.command('sharepoint').alias('sp').description('Read SharePoint sites, libraries, files, and lists')
55
+ .option('--site <url-or-id>', 'Override the connection\'s default site');
56
+
57
+ function action(command, title, load, { curate = (value) => value, format = formatGraph, columns, records = false, interactive = false, connected = true } = {}) {
58
+ command.action(async function (...args) {
59
+ const mode = getOutputMode(this);
60
+ const s = mode === 'rich' && !interactive ? spinner(title).start() : null;
61
+ try {
62
+ if (interactive && (mode !== 'rich' || !process.stdin.isTTY || !process.stdout.isTTY)) {
63
+ throw new Error('Setup and sign-in require an interactive terminal without --json or --plain. Data commands never open a browser.');
64
+ }
65
+ const result = await load(connected ? connect(this) : {}, args, this.optsWithGlobals());
66
+ const data = Array.isArray(result) ? result.map(curate) : curate(result);
67
+ s?.success();
68
+ if (data !== undefined) format(data, { mode, title, columns, records });
69
+ } catch (err) {
70
+ s?.error();
71
+ const message = graphErrorMessage(err);
72
+ console.error(mode === 'json' ? JSON.stringify({ error: { message, status: err.status || null } }) : message);
73
+ process.exitCode = 1;
74
+ }
75
+ });
76
+ return command;
77
+ }
78
+
79
+ const bounded = (command) => command.option('-n, --limit <count>', 'Maximum results (1-1000; more results may exist)', limit, 25);
80
+ const mailFolder = (command) => command.option('--folder <id-or-name>', 'Folder ID or well-known name such as inbox');
81
+ const idName = [['ID', (x) => x.id, 38], ['Name', (x) => x.name, 48]];
82
+
83
+ action(graph.command('setup').description('Add or edit a Graph connection: tenant ID, client ID, and default SharePoint site'), 'Graph setup', (_, __, opts) => setup(opts.connection), { interactive: true, connected: false });
84
+ action(graph.command('login').description('Sign in through your browser; no client secret')
85
+ .option('--shared-mail', 'Also request Mail.Read.Shared for shared mailboxes and folders')
86
+ .option('--calendars', 'Also request calendar details and free/busy permissions')
87
+ .option('--all', 'Sign in once for all supported read-only services, including shared email and calendars'), 'Graph sign-in', ({ auth }, _, opts) => auth.login(opts), { interactive: true });
88
+ action(graph.command('status').description('Show the locally cached account (does not check token validity)'), 'Cached Graph account', ({ auth }) => auth.status());
89
+ action(graph.command('logout').description('Remove this application’s local cached account; does not revoke server grants'), 'Graph sign-out', ({ auth }) => auth.logout());
90
+
91
+ const dateWindow = (command) => command
92
+ .option('--from <datetime>', 'ISO start with timezone; defaults to now')
93
+ .option('--to <datetime>', 'ISO end with timezone; defaults to 30 days after start (maximum 366 days)');
94
+ action(bounded(calendar.command('list').description('List your calendars, including shared calendars added to your mailbox')), 'Calendars', ({ client }, _, opts) => client.calendars(opts), {
95
+ curate: curateCalendarJson, columns: [...idName, ['Owner', (x) => x.owner?.address, 32]],
96
+ });
97
+ action(bounded(dateWindow(calendar.command('events').description('Read recurring occurrences and appointments in a date range, in UTC'))), 'Calendar events', ({ client }, _, opts) => client.calendarEvents(opts), {
98
+ curate: curateEventJson,
99
+ columns: [['ID', (x) => x.id, 32], ['Subject', (x) => x.subject, 44], ['Start (UTC)', (x) => x.start?.dateTime, 30], ['Location', (x) => x.location, 28]],
100
+ });
101
+ action(dateWindow(calendar.command('next').description('Show the next event in the selected calendar; skip cancelled, declined, and already-started events')), 'Next calendar event', ({ client }, _, opts) => client.nextEvent(opts), { curate: curateNextEventJson, format: formatNextEvent });
102
+ action(calendar.command('availability <addresses...>').description('Read free/busy times for users or rooms; no calendar changes')
103
+ .option('--from <datetime>', 'ISO start with timezone; defaults to now')
104
+ .option('--to <datetime>', 'ISO end with timezone; defaults to 24 hours later (range under 62 days)')
105
+ .option('--interval <minutes>', 'Slot size, 5–1440 minutes', '30'), 'Calendar availability', async ({ client }, [addresses], opts) => {
106
+ if (opts.mailbox || opts.calendar) throw new Error('Availability uses the supplied email addresses, not --mailbox or --calendar.');
107
+ const result = await client.availability(addresses, opts);
108
+ if (result.schedules.some((schedule) => schedule.error)) process.exitCode = 1;
109
+ return result;
110
+ }, { curate: curateAvailabilityJson, format: formatAvailability });
111
+ action(calendar.command('view <event-id>').description('Read an event from the selected calendar'), 'Calendar event', ({ client }, [id], opts) => client.calendarEvent(id, opts), { curate: curateEventJson });
112
+
113
+ action(bounded(teams.command('chats').alias('list').description('List your chats; expanded members may be limited to 25')), 'Teams chats', ({ client }, _, opts) => client.chats(opts), {
114
+ curate: curateChatJson,
115
+ columns: [['ID', (x) => x.id, 42], ['Topic / members', (x) => x.topic || x.members.map((m) => m.name || m.email).join(', '), 48], ['Type', (x) => x.type, 16]],
116
+ });
117
+ action(teams.command('view <chat-id>').description('Show a chat and its members'), 'Teams chat', ({ client }, [id]) => client.chat(id), { curate: curateChatJson });
118
+ action(bounded(teams.command('messages <chat-id>').description('Read chat messages, newest first')), 'Teams messages', ({ client }, [id], opts) => client.chatMessages(id, opts), { curate: curateChatMessageJson, records: true });
119
+ action(teams.command('message <chat-id> <message-id>').description('Read one chat message'), 'Teams message', ({ client }, [chat, message]) => client.chatMessage(chat, message), { curate: curateChatMessageJson });
120
+
121
+ action(bounded(mail.command('folders').description('List mailbox folders')
122
+ .option('--parent <folder-id>', 'List child folders of this folder')), 'Mail folders', ({ client }, _, opts) => client.mailFolders(opts), {
123
+ curate: curateFolderJson, columns: [...idName, ['Unread / total', (x) => `${x.unread} / ${x.total}`, 18]],
124
+ });
125
+ const mailColumns = [['ID', (x) => x.id, 36], ['Subject', (x) => x.subject, 44], ['From', (x) => x.from?.address, 30], ['Received', (x) => x.receivedAt, 26]];
126
+ action(bounded(mailFolder(mail.command('list').alias('messages').description('List email across all folders, newest first; use --folder inbox for the inbox'))), 'Email', ({ client }, _, opts) => client.mailMessages(opts), { curate: curateMailJson, columns: mailColumns });
127
+ action(bounded(mailFolder(mail.command('search <query>').description('Search email using Microsoft message search syntax'))), 'Email search', ({ client }, [search], opts) => client.mailMessages({ ...opts, search }), { curate: curateMailJson, columns: mailColumns });
128
+ action(mailFolder(mail.command('view <message-id>').description('Read email content without changing its read status')), 'Email message', ({ client }, [id], opts) => client.mailMessage(id, opts), { curate: curateMailJson });
129
+
130
+ action(bounded(sharepoint.command('sites <query>').description('Find sites by text')), 'SharePoint sites', ({ client }, [query], opts) => client.sites(query, opts), { curate: curateSiteJson, columns: idName });
131
+ action(sharepoint.command('site [url-or-id]').description('Show a site; defaults to the connection\'s default site'), 'SharePoint site', ({ client }, [site], opts) => client.site(site || opts.site), { curate: curateSiteJson });
132
+ action(bounded(sharepoint.command('libraries').alias('drives').description('List the site’s document libraries')), 'Document libraries', ({ client }, _, opts) => client.drives(opts), { curate: curateDriveJson, columns: idName });
133
+ action(bounded(sharepoint.command('files <drive-id>').description('List files and folders in a document library')
134
+ .option('--folder <item-id>', 'Folder item ID')
135
+ .option('--path <path>', 'Folder path relative to the library root')), 'SharePoint files', ({ client }, [drive], opts) => client.files(drive, opts), {
136
+ curate: curateFileJson, columns: [...idName, ['Type', (x) => x.type, 12], ['Bytes', (x) => x.size, 14]],
137
+ });
138
+ action(sharepoint.command('file <drive-id> <item-id>').description('Show file or folder metadata'), 'SharePoint file', ({ client }, [drive, id]) => client.file(drive, id), { curate: curateFileJson });
139
+ action(sharepoint.command('download <drive-id> <item-id> <destination>').description('Download a file to a new local path; never overwrite')
140
+ .option('--timeout <seconds>', 'Per-request download timeout, 1–86400 seconds', '120'), 'SharePoint download', ({ client }, [drive, id, destination], opts) => client.download(drive, id, destination, opts));
141
+ action(bounded(sharepoint.command('lists').description('List SharePoint lists')), 'SharePoint lists', ({ client }, _, opts) => client.lists(opts), { curate: curateListJson, columns: idName });
142
+ action(bounded(sharepoint.command('items <list-id>').description('Read list items and their fields')), 'SharePoint list items', ({ client }, [list], opts) => client.listItems(list, opts), { curate: curateListItemJson, records: true });
143
+ action(sharepoint.command('item <list-id> <item-id>').description('Read one list item and its fields'), 'SharePoint list item', ({ client }, [list, item], opts) => client.listItem(list, item, opts), { curate: curateListItemJson });
144
+ return graph;
145
+ }
146
+
147
+ export default createGraphCommand();
@@ -15,6 +15,7 @@ import {
15
15
  import { spinner } from '../utils/output.js';
16
16
  import { handleError } from '../utils/errors.js';
17
17
  import { getOutputMode, outputJson } from '../utils/mode.js';
18
+ import { connectionFor } from '../utils/connection.js';
18
19
 
19
20
  const jira = new Command('jira')
20
21
  .description('Jira tickets');
@@ -26,7 +27,7 @@ jira.command('view <key>')
26
27
  const mode = getOutputMode(this);
27
28
  const s = mode === 'rich' ? spinner(`Fetching ${key}...`).start() : null;
28
29
  try {
29
- const issue = await getIssue(key);
30
+ const issue = await getIssue(connectionFor(this, 'jira'), key);
30
31
  if (s) s.success({ text: key });
31
32
  if (mode === 'json') outputJson(curateIssueJson(issue));
32
33
  else if (mode === 'plain') plainIssueView(issue);
@@ -43,7 +44,7 @@ jira.command('comments <key>')
43
44
  const mode = getOutputMode(this);
44
45
  const s = mode === 'rich' ? spinner(`Fetching comments for ${key}...`).start() : null;
45
46
  try {
46
- const comments = await getIssueComments(key);
47
+ const comments = await getIssueComments(connectionFor(this, 'jira'), key);
47
48
  if (s) s.success({ text: `${comments.length} comment(s)` });
48
49
  if (mode === 'json') outputJson(curateCommentsJson(comments));
49
50
  else if (mode === 'plain') plainIssueComments(comments);
@@ -65,7 +66,7 @@ jira.command('search <jql>')
65
66
  const startAt = (parseInt(opts.page, 10) - 1) * perPage;
66
67
  const s = mode === 'rich' ? spinner('Searching...').start() : null;
67
68
  try {
68
- const result = await searchIssues(jql, startAt, perPage);
69
+ const result = await searchIssues(connectionFor(this, 'jira'), jql, startAt, perPage);
69
70
  if (s) s.success({ text: `${result.total} issue(s) matched` });
70
71
  if (mode === 'json') outputJson(curateSearchJson(result));
71
72
  else if (mode === 'plain') plainIssueSearch(result);
@@ -83,7 +84,7 @@ jira.command('children <key>')
83
84
  const mode = getOutputMode(this);
84
85
  const s = mode === 'rich' ? spinner(`Fetching children of ${key}...`).start() : null;
85
86
  try {
86
- const result = await getIssueChildren(key);
87
+ const result = await getIssueChildren(connectionFor(this, 'jira'), key);
87
88
  if (s) s.success({ text: `${result.total} ${result.source === 'epic' ? 'issue(s) in epic' : 'subtask(s)'}` });
88
89
  if (mode === 'json') outputJson(curateChildrenJson(result));
89
90
  else if (mode === 'plain') plainIssueChildren(result);
@@ -101,7 +102,7 @@ jira.command('mine')
101
102
  const mode = getOutputMode(this);
102
103
  const s = mode === 'rich' ? spinner('Fetching my issues...').start() : null;
103
104
  try {
104
- const result = await getMyIssues();
105
+ const result = await getMyIssues(connectionFor(this, 'jira'));
105
106
  if (s) s.success({ text: `${result.total} issue(s)` });
106
107
  if (mode === 'json') outputJson(curateSearchJson(result));
107
108
  else if (mode === 'plain') plainIssueSearch(result);
@@ -14,23 +14,7 @@ import {
14
14
  import { spinner } from '../utils/output.js';
15
15
  import { handleError } from '../utils/errors.js';
16
16
  import { getOutputMode, outputJson } from '../utils/mode.js';
17
-
18
- function withMode(cmd, fetchFn, { json, plain, rich }) {
19
- return async (...args) => {
20
- const mode = getOutputMode(cmd);
21
- const s = mode === 'rich' ? spinner('Loading...').start() : null;
22
- try {
23
- const data = await fetchFn(...args);
24
- if (s) s.success({ text: '' });
25
- if (mode === 'json') outputJson(json(data));
26
- else if (mode === 'plain') plain(data);
27
- else rich(data);
28
- } catch (err) {
29
- if (s) s.error({ text: 'Failed' });
30
- handleError(err, err.url || 'bitbucket');
31
- }
32
- };
33
- }
17
+ import { connectionFor } from '../utils/connection.js';
34
18
 
35
19
  const pr = new Command('pr')
36
20
  .description('Bitbucket pull requests');
@@ -45,7 +29,7 @@ pr.command('list')
45
29
  const mode = getOutputMode(this);
46
30
  const s = mode === 'rich' ? spinner('Fetching pull requests...').start() : null;
47
31
  try {
48
- const prs = await listPullRequests({ state: opts.state, author: opts.author, repo: opts.repo });
32
+ const prs = await listPullRequests(connectionFor(this, 'bitbucket'), { state: opts.state, author: opts.author, repo: opts.repo });
49
33
  if (s) s.success({ text: `${prs.length} pull request(s) found` });
50
34
  if (mode === 'json') outputJson(curateListJson(prs));
51
35
  else if (mode === 'plain') plainPrList(prs);
@@ -64,7 +48,7 @@ pr.command('view <id>')
64
48
  const mode = getOutputMode(this);
65
49
  const s = mode === 'rich' ? spinner(`Fetching PR #${id}...`).start() : null;
66
50
  try {
67
- const data = await getPullRequest(id, opts.repo);
51
+ const data = await getPullRequest(connectionFor(this, 'bitbucket'), id, opts.repo);
68
52
  if (s) s.success({ text: `PR #${id}` });
69
53
  if (mode === 'json') outputJson(curateViewJson(data));
70
54
  else if (mode === 'plain') plainPrView(data);
@@ -82,7 +66,7 @@ pr.command('comments <id>')
82
66
  const mode = getOutputMode(this);
83
67
  const s = mode === 'rich' ? spinner(`Fetching comments for PR #${id}...`).start() : null;
84
68
  try {
85
- const comments = await getPullRequestComments(id, opts.repo);
69
+ const comments = await getPullRequestComments(connectionFor(this, 'bitbucket'), id, opts.repo);
86
70
  if (s) s.success({ text: `${comments.length} comment(s)` });
87
71
  if (mode === 'json') outputJson(curateCommentsJson(comments));
88
72
  else if (mode === 'plain') plainPrComments(comments);
@@ -100,7 +84,7 @@ pr.command('diff <id>')
100
84
  const mode = getOutputMode(this);
101
85
  const s = mode === 'rich' ? spinner(`Fetching diffstat for PR #${id}...`).start() : null;
102
86
  try {
103
- const files = await getPullRequestDiffstat(id, opts.repo);
87
+ const files = await getPullRequestDiffstat(connectionFor(this, 'bitbucket'), id, opts.repo);
104
88
  if (s) s.success({ text: `${files.length} file(s) changed` });
105
89
  if (mode === 'json') outputJson(curateDiffstatJson(files));
106
90
  else if (mode === 'plain') plainPrDiffstat(files);
@@ -118,7 +102,7 @@ pr.command('activity <id>')
118
102
  const mode = getOutputMode(this);
119
103
  const s = mode === 'rich' ? spinner(`Fetching activity for PR #${id}...`).start() : null;
120
104
  try {
121
- const activities = await getPullRequestActivity(id, opts.repo);
105
+ const activities = await getPullRequestActivity(connectionFor(this, 'bitbucket'), id, opts.repo);
122
106
  if (s) s.success({ text: `${activities.length} activity item(s)` });
123
107
  if (mode === 'json') outputJson(curateActivityJson(activities));
124
108
  else if (mode === 'plain') plainPrActivity(activities);
@@ -16,6 +16,7 @@ import {
16
16
  import { spinner } from '../utils/output.js';
17
17
  import { handleError } from '../utils/errors.js';
18
18
  import { getOutputMode, outputJson } from '../utils/mode.js';
19
+ import { connectionFor } from '../utils/connection.js';
19
20
 
20
21
  const sd = new Command('sd')
21
22
  .alias('servicedesk')
@@ -28,7 +29,7 @@ sd.command('view <key>')
28
29
  const mode = getOutputMode(this);
29
30
  const s = mode === 'rich' ? spinner(`Fetching ${key}...`).start() : null;
30
31
  try {
31
- const issue = await getIssue(key);
32
+ const issue = await getIssue(connectionFor(this, 'serviceDesk'), key);
32
33
  if (s) s.success({ text: key });
33
34
  if (mode === 'json') outputJson(curateIssueJson(issue));
34
35
  else if (mode === 'plain') plainIssueView(issue);
@@ -45,7 +46,7 @@ sd.command('comments <key>')
45
46
  const mode = getOutputMode(this);
46
47
  const s = mode === 'rich' ? spinner(`Fetching comments for ${key}...`).start() : null;
47
48
  try {
48
- const comments = await getIssueComments(key);
49
+ const comments = await getIssueComments(connectionFor(this, 'serviceDesk'), key);
49
50
  if (s) s.success({ text: `${comments.length} comment(s)` });
50
51
  if (mode === 'json') outputJson(curateCommentsJson(comments));
51
52
  else if (mode === 'plain') plainIssueComments(comments);
@@ -66,7 +67,7 @@ sd.command('search <jql>')
66
67
  const perPage = parseInt(opts.perPage, 10);
67
68
  const s = mode === 'rich' ? spinner('Searching...').start() : null;
68
69
  try {
69
- const result = await searchIssues(jql, perPage, opts.next || '');
70
+ const result = await searchIssues(connectionFor(this, 'serviceDesk'), jql, perPage, opts.next || '');
70
71
  if (s) s.success({ text: `${result.issues.length} issue(s) returned` });
71
72
  if (mode === 'json') outputJson(curateSearchJson(result));
72
73
  else if (mode === 'plain') plainIssueSearch(result);
@@ -78,12 +79,12 @@ sd.command('search <jql>')
78
79
  });
79
80
 
80
81
  sd.command('queues')
81
- .description('List Service Desk queues for the default project')
82
+ .description('List Service Desk queues for the connection\'s default project')
82
83
  .action(async function () {
83
84
  const mode = getOutputMode(this);
84
85
  const s = mode === 'rich' ? spinner('Fetching queues...').start() : null;
85
86
  try {
86
- const queues = await listQueues();
87
+ const queues = await listQueues(connectionFor(this, 'serviceDesk'));
87
88
  if (s) s.success({ text: `${queues.length} queue(s)` });
88
89
  if (mode === 'json') outputJson(curateQueuesJson(queues));
89
90
  else if (mode === 'plain') plainQueueList(queues);
@@ -103,7 +104,7 @@ sd.command('queue <nameOrId>')
103
104
  const perPage = parseInt(opts.perPage, 10);
104
105
  const s = mode === 'rich' ? spinner(`Fetching queue "${nameOrId}"...`).start() : null;
105
106
  try {
106
- const result = await searchByQueue(nameOrId, perPage, opts.next || '');
107
+ const result = await searchByQueue(connectionFor(this, 'serviceDesk'), nameOrId, perPage, opts.next || '');
107
108
  if (s) s.success({ text: `${result.issues.length} issue(s) in "${result.queue.name}"` });
108
109
  if (mode === 'json') outputJson(curateSearchJson(result));
109
110
  else if (mode === 'plain') plainIssueSearch(result);
@@ -121,7 +122,7 @@ sd.command('mine')
121
122
  const mode = getOutputMode(this);
122
123
  const s = mode === 'rich' ? spinner('Fetching my tickets...').start() : null;
123
124
  try {
124
- const result = await getMyIssues();
125
+ const result = await getMyIssues(connectionFor(this, 'serviceDesk'));
125
126
  if (s) s.success({ text: `${result.issues.length} ticket(s)` });
126
127
  if (mode === 'json') outputJson(curateSearchJson(result));
127
128
  else if (mode === 'plain') plainIssueSearch(result);
@@ -0,0 +1,113 @@
1
+ import * as p from '@clack/prompts';
2
+ import pc from 'picocolors';
3
+ import {
4
+ types, label, listConnections, isDefault, saveConnection, validateName, resolveIndex, fieldProblem,
5
+ } from './store.js';
6
+
7
+ function answer(value) {
8
+ if (p.isCancel(value)) {
9
+ p.cancel('Setup cancelled.');
10
+ process.exit(0);
11
+ }
12
+ return (value ?? '').trim();
13
+ }
14
+
15
+ export function describe(connections, index) {
16
+ const connection = connections[index];
17
+ const { title } = types[connection.type];
18
+ const sameType = connections.filter((c) => c.type === connection.type).length;
19
+ const marker = sameType > 1 && isDefault(connections, index) ? ', default' : '';
20
+ return `${label(connection)} (${title}${marker})`;
21
+ }
22
+
23
+ async function chooseType() {
24
+ return answer(await p.select({
25
+ message: 'Connection type',
26
+ options: Object.entries(types).map(([value, { title }]) => ({ value, label: title })),
27
+ }));
28
+ }
29
+
30
+ // Service Desk Cloud often shares an Atlassian account with Confluence Cloud.
31
+ async function reusableCredentials(connections) {
32
+ const sources = connections.filter((c) => c.type === 'confluence' && c.email && c.token);
33
+ if (!sources.length) return null;
34
+ const choice = answer(await p.select({
35
+ message: 'Service Desk credentials',
36
+ options: [
37
+ ...sources.map((c) => ({ value: label(c), label: `Reuse email and token from Confluence connection "${label(c)}"` })),
38
+ { value: '', label: 'Enter new credentials' },
39
+ ],
40
+ }));
41
+ return sources.find((c) => label(c) === choice) ?? null;
42
+ }
43
+
44
+ async function promptConnection(type, existing = {}, index = -1) {
45
+ const connections = listConnections();
46
+ const connection = { type };
47
+ if (existing.default) connection.default = true;
48
+ if (types[type].note) p.note(types[type].note, types[type].title);
49
+
50
+ const unnamedTaken = connections.some((c, i) => i !== index && c.type === type && !c.name);
51
+ connection.name = answer(await p.text({
52
+ message: unnamedTaken ? 'Connection name' : 'Connection name (optional)',
53
+ placeholder: unnamedTaken ? 'work' : `leave empty to refer to it as "${type}"`,
54
+ initialValue: existing.name || '',
55
+ validate: (v) => validateName(connections, { type, name: (v ?? '').trim() }, index),
56
+ }));
57
+
58
+ const reused = type === 'serviceDesk' ? await reusableCredentials(connections) : null;
59
+
60
+ for (const [key, field] of Object.entries(types[type].fields)) {
61
+ if (reused && (key === 'email' || key === 'token')) {
62
+ connection[key] = reused[key];
63
+ continue;
64
+ }
65
+ const keep = field.secret && existing[key];
66
+ const message = keep ? `${field.message} (leave empty to keep the current one)` : field.message;
67
+ const validate = (v) => {
68
+ const value = (v ?? '').trim();
69
+ if (!value) return field.required && !keep ? 'Required' : undefined;
70
+ return fieldProblem(type, key, value);
71
+ };
72
+ const value = field.secret
73
+ ? answer(await p.password({ message, validate }))
74
+ : answer(await p.text({ message, placeholder: field.placeholder, initialValue: existing[key] || '', validate }));
75
+ connection[key] = value || (keep ? existing[key] : '');
76
+ }
77
+
78
+ saveConnection(connection, index);
79
+ return connection;
80
+ }
81
+
82
+ export async function addConnection(type, name) {
83
+ p.intro(pc.cyan('Add a connection'));
84
+ const connection = await promptConnection(type || await chooseType(), { name });
85
+ p.outro(pc.green(`Saved connection "${label(connection)}"`));
86
+ }
87
+
88
+ export async function editConnection(ref) {
89
+ const index = resolveIndex(ref);
90
+ const existing = listConnections()[index];
91
+ p.intro(pc.cyan(`Edit connection "${label(existing)}"`));
92
+ const connection = await promptConnection(existing.type, existing, index);
93
+ p.outro(pc.green(`Saved connection "${label(connection)}"`));
94
+ }
95
+
96
+ export async function runSetup() {
97
+ p.intro(pc.cyan('DevTool Setup'));
98
+ for (;;) {
99
+ const connections = listConnections();
100
+ const choice = answer(await p.select({
101
+ message: connections.length ? 'Edit a connection, or add a new one' : 'No connections configured yet',
102
+ options: [
103
+ ...connections.map((_, i) => ({ value: String(i), label: `Edit ${describe(connections, i)}` })),
104
+ { value: 'add', label: 'Add a connection' },
105
+ { value: 'done', label: 'Done' },
106
+ ],
107
+ }));
108
+ if (choice === 'done') break;
109
+ if (choice === 'add') await promptConnection(await chooseType());
110
+ else await promptConnection(connections[choice].type, connections[choice], Number(choice));
111
+ }
112
+ p.outro(pc.green('Configuration saved!'));
113
+ }