ticketlens 0.1.11 → 0.1.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "Jira CLI for developers — fetch ticket context, triage your queue, and stop tab-switching. Zero dependencies, all local.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,6 +11,7 @@ import { createStyler } from './ansi.mjs';
11
11
  import { createSession } from './banner.mjs';
12
12
  import { classifyError } from './error-classifier.mjs';
13
13
  import { fetchCurrentUser, fetchStatuses } from './jira-client.mjs';
14
+ import { resolveAdapter } from './resolve-adapter.mjs';
14
15
  import { loadProfiles, loadCredentials, saveProfile } from './profile-resolver.mjs';
15
16
  import { promptSelect } from './select-prompt.mjs';
16
17
  import { parseAge } from './cache-manager.mjs';
@@ -26,6 +27,26 @@ const RETRY_OPTIONS = [
26
27
  { label: 'Skip', sublabel: 'Abandon connection changes — no changes saved', value: 'skip' },
27
28
  ];
28
29
 
30
+ function getTrackerType(profile) {
31
+ if (profile.auth === 'linear') return 'linear';
32
+ if (profile.auth === 'github') return 'github';
33
+ return 'jira';
34
+ }
35
+
36
+ function getUrlLabel(trackerType) {
37
+ if (trackerType === 'linear') return 'Linear workspace URL';
38
+ if (trackerType === 'github') return 'GitHub URL';
39
+ return 'Jira URL';
40
+ }
41
+
42
+ function getTokenLabel(trackerType, auth) {
43
+ if (trackerType === 'linear') return 'Linear API key';
44
+ if (trackerType === 'github') return 'GitHub token';
45
+ if (auth === 'cloud') return 'API token';
46
+ if (auth === 'pat') return 'Personal access token';
47
+ return 'Password';
48
+ }
49
+
29
50
  // ── Main ──────────────────────────────────────────────────────────────────────
30
51
 
31
52
  export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {}) {
@@ -54,6 +75,10 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
54
75
  const profileCreds = creds[target] || {};
55
76
  const hostname = (() => { try { return new URL(profile.baseUrl).hostname; } catch { return profile.baseUrl; } })();
56
77
 
78
+ const trackerType = getTrackerType(profile);
79
+ const isJira = trackerType === 'jira';
80
+ const urlLabel = getUrlLabel(trackerType);
81
+
57
82
  // Header box
58
83
  const headerLines = [
59
84
  `Editing profile ${s.bold(s.cyan(`"${target}"`))}`,
@@ -80,35 +105,36 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
80
105
 
81
106
  // ── URL ───────────────────────────────────────────────────────────────────
82
107
  const urlTyped = await promptText(
83
- s.dim('Jira URL') + s.dim(` [current: ${profile.baseUrl}]:`),
108
+ s.dim(urlLabel) + s.dim(` [current: ${profile.baseUrl}]:`),
84
109
  { stream, defaultValue: profile.baseUrl }
85
110
  );
86
111
  if (urlTyped !== profile.baseUrl) {
87
- // Auto-prefix https:// when the user omits the protocol
88
112
  url = /^https?:\/\//i.test(urlTyped)
89
113
  ? urlTyped.replace(/\/$/, '')
90
114
  : `https://${urlTyped.replace(/\/$/, '')}`;
91
115
  if (url !== urlTyped) stream.write(` ${s.dim('○')} Interpreted as ${url}\n`);
92
116
  }
93
117
 
94
- // ── Auth type ─────────────────────────────────────────────────────────────
95
- const isCloud = /\.atlassian\.net(\/|$)/i.test(url);
96
- if (isCloud && auth !== 'cloud') {
97
- auth = 'cloud';
98
- stream.write(` ${s.green('')} Jira Cloud detected — using email + API token\n`);
99
- } else if (!isCloud) {
100
- const currentIdx = SERVER_AUTH_TYPES.findIndex(a => a.value === auth);
101
- stream.write(`\n ${s.dim('Auth type:')}\n\n`);
102
- const authIdx = await promptSelect(SERVER_AUTH_TYPES, {
103
- stream,
104
- hint: '↑/↓ select Enter confirm',
105
- initialIndex: Math.max(0, currentIdx),
106
- });
107
- if (authIdx !== null) auth = SERVER_AUTH_TYPES[authIdx].value;
118
+ // ── Auth type (Jira only) ─────────────────────────────────────────────────
119
+ if (isJira) {
120
+ const isCloud = /\.atlassian\.net(\/|$)/i.test(url);
121
+ if (isCloud && auth !== 'cloud') {
122
+ auth = 'cloud';
123
+ stream.write(` ${s.green('✔')} Jira Cloud detected — using email + API token\n`);
124
+ } else if (!isCloud) {
125
+ const currentIdx = SERVER_AUTH_TYPES.findIndex(a => a.value === auth);
126
+ stream.write(`\n ${s.dim('Auth type:')}\n\n`);
127
+ const authIdx = await promptSelect(SERVER_AUTH_TYPES, {
128
+ stream,
129
+ hint: '↑/↓ select Enter confirm',
130
+ initialIndex: Math.max(0, currentIdx),
131
+ });
132
+ if (authIdx !== null) auth = SERVER_AUTH_TYPES[authIdx].value;
133
+ }
108
134
  }
109
135
 
110
- // ── Email / username ──────────────────────────────────────────────────────
111
- if (auth === 'cloud' || auth === 'basic') {
136
+ // ── Email / username (Jira only) ──────────────────────────────────────────
137
+ if (isJira && (auth === 'cloud' || auth === 'basic')) {
112
138
  const emailHint = email ? s.dim(` [current: ${email}]`) : '';
113
139
  const emailLabel = (auth === 'cloud' ? s.dim('Email') : s.dim('Username')) + emailHint + s.dim(':');
114
140
  email = await promptText(emailLabel, {
@@ -122,13 +148,9 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
122
148
  });
123
149
  }
124
150
 
125
- // ── Token / PAT / password ────────────────────────────────────────────────
151
+ // ── Token / PAT / password / API key ─────────────────────────────────────
126
152
  const tokenHint = existingToken ? s.dim(' [keep existing]') : '';
127
- const tokenLabel = (auth === 'cloud'
128
- ? s.dim('API token')
129
- : auth === 'pat'
130
- ? s.dim('Personal access token')
131
- : s.dim('Password')) + tokenHint + s.dim(':');
153
+ const tokenLabel = s.dim(getTokenLabel(trackerType, auth)) + tokenHint + s.dim(':');
132
154
  token = await promptSecret(tokenLabel, { stream, existingValue: existingToken });
133
155
  const tokenChanged = token !== existingToken;
134
156
 
@@ -144,11 +166,11 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
144
166
  let startFrom = 'test';
145
167
 
146
168
  setupLoop: while (true) {
147
- // Re-prompt URL + auth
169
+ // Re-prompt URL + auth (on retry)
148
170
  if (startFrom === 'url') {
149
171
  stream.write('\n');
150
172
  const reTyped = await promptText(
151
- s.dim('Jira URL') + s.dim(` [current: ${url}]:`),
173
+ s.dim(urlLabel) + s.dim(` [current: ${url}]:`),
152
174
  { stream, defaultValue: url }
153
175
  );
154
176
  if (reTyped !== url) {
@@ -157,21 +179,24 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
157
179
  : `https://${reTyped.replace(/\/$/, '')}`;
158
180
  if (url !== reTyped) stream.write(` ${s.dim('○')} Interpreted as ${url}\n`);
159
181
  }
160
- const reCloud = /\.atlassian\.net(\/|$)/i.test(url);
161
- if (reCloud) {
162
- auth = 'cloud';
163
- stream.write(` ${s.green('✔')} Jira Cloud detected — using email + API token\n\n`);
164
- } else if (auth === 'cloud') {
165
- stream.write(`\n ${s.dim('Auth type:')}\n\n`);
166
- const idx = await promptSelect(SERVER_AUTH_TYPES, { stream, hint: '↑/↓ select Enter confirm' });
167
- if (idx !== null) auth = SERVER_AUTH_TYPES[idx].value;
182
+
183
+ if (isJira) {
184
+ const reCloud = /\.atlassian\.net(\/|$)/i.test(url);
185
+ if (reCloud) {
186
+ auth = 'cloud';
187
+ stream.write(` ${s.green('')} Jira Cloud detected — using email + API token\n\n`);
188
+ } else if (auth === 'cloud') {
189
+ stream.write(`\n ${s.dim('Auth type:')}\n\n`);
190
+ const idx = await promptSelect(SERVER_AUTH_TYPES, { stream, hint: '↑/↓ select Enter confirm' });
191
+ if (idx !== null) auth = SERVER_AUTH_TYPES[idx].value;
192
+ }
168
193
  }
169
194
  startFrom = 'creds';
170
195
  }
171
196
 
172
197
  // Re-prompt email + token (pre-populated)
173
198
  if (startFrom === 'creds') {
174
- if (auth === 'cloud' || auth === 'basic') {
199
+ if (isJira && (auth === 'cloud' || auth === 'basic')) {
175
200
  const eHint = email ? s.dim(` [current: ${email}]`) : '';
176
201
  const eLabel = (auth === 'cloud' ? s.dim('Email') : s.dim('Username')) + eHint + s.dim(':');
177
202
  email = await promptText(eLabel, {
@@ -185,24 +210,30 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
185
210
  });
186
211
  }
187
212
  const tHint = token ? s.dim(' [keep existing]') : '';
188
- const tLabel = (auth === 'cloud' ? s.dim('API token') : auth === 'pat' ? s.dim('Personal access token') : s.dim('Password')) + tHint + s.dim(':');
213
+ const tLabel = s.dim(getTokenLabel(trackerType, auth)) + tHint + s.dim(':');
189
214
  token = await promptSecret(tLabel, { stream, existingValue: token });
190
215
  }
191
216
 
192
217
  // Test connection
193
- const testEnv = {
194
- JIRA_BASE_URL: url,
195
- JIRA_EMAIL: email,
196
- JIRA_API_TOKEN: auth !== 'pat' ? token : '',
197
- JIRA_PAT: auth === 'pat' ? token : '',
198
- };
199
- const testVersion = auth === 'cloud' ? 3 : 2;
200
- const session = createSession({ baseUrl: url, profileName: target, email: email || undefined, pat: auth === 'pat' ? token : undefined }, { stream });
218
+ const session = createSession(
219
+ { baseUrl: url, profileName: target, email: email || undefined, pat: auth === 'pat' ? token : undefined },
220
+ { stream },
221
+ );
201
222
  stream.write('\n');
202
223
  session.spin('Testing connection...');
203
224
 
204
225
  try {
205
- await fetchCurrentUser({ env: testEnv, apiVersion: testVersion });
226
+ if (isJira) {
227
+ const testEnv = {
228
+ JIRA_BASE_URL: url,
229
+ JIRA_EMAIL: email,
230
+ JIRA_API_TOKEN: auth !== 'pat' ? token : '',
231
+ JIRA_PAT: auth === 'pat' ? token : '',
232
+ };
233
+ await fetchCurrentUser({ env: testEnv, apiVersion: auth === 'cloud' ? 3 : 2 });
234
+ } else {
235
+ await resolveAdapter({ baseUrl: url, auth: trackerType, apiToken: token }).fetchCurrentUser();
236
+ }
206
237
  session.connected();
207
238
  connected = true;
208
239
  break setupLoop;
@@ -242,7 +273,7 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
242
273
  }
243
274
  const ticketPrefixes = [...existing];
244
275
 
245
- // Project path (single — used for auto-profile detection from cwd)
276
+ // Project path
246
277
  const home = homedir();
247
278
  const cwd = process.cwd();
248
279
  const cwdDisplay = cwd.startsWith(home) ? '~' + cwd.slice(home.length) : cwd;
@@ -275,8 +306,6 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
275
306
  }
276
307
 
277
308
  // ── Triage statuses (merge semantics) ────────────────────────────────────
278
- // Typing new values ADDS them to the current list (with partial matching).
279
- // Existing statuses are never removed by this prompt.
280
309
  const currentStatuses = profile.triageStatuses?.length
281
310
  ? profile.triageStatuses
282
311
  : ['In Progress', 'Code Review', 'QA'];
@@ -292,21 +321,24 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
292
321
  if (statusInput) {
293
322
  const newEntries = statusInput.split(',').map(v => v.trim()).filter(Boolean);
294
323
  const existingLower = new Set(currentStatuses.map(n => n.toLowerCase()));
295
- // Only validate statuses that aren't already in the list
296
324
  const toValidate = newEntries.filter(n => !existingLower.has(n.toLowerCase()));
297
325
 
298
326
  if (toValidate.length > 0) {
299
- const validateEnv = {
300
- JIRA_BASE_URL: url,
301
- JIRA_EMAIL: email,
302
- JIRA_API_TOKEN: auth !== 'pat' ? token : '',
303
- JIRA_PAT: auth === 'pat' ? token : '',
304
- };
305
- const validateApiVersion = auth === 'cloud' ? 3 : 2;
306
-
307
327
  stream.write(` ${s.dim('Validating new statuses...')}\n`);
308
328
  try {
309
- const available = await fetchStatuses({ env: validateEnv, apiVersion: validateApiVersion });
329
+ let available;
330
+ if (isJira) {
331
+ const validateEnv = {
332
+ JIRA_BASE_URL: url,
333
+ JIRA_EMAIL: email,
334
+ JIRA_API_TOKEN: auth !== 'pat' ? token : '',
335
+ JIRA_PAT: auth === 'pat' ? token : '',
336
+ };
337
+ available = await fetchStatuses({ env: validateEnv, apiVersion: auth === 'cloud' ? 3 : 2 });
338
+ } else {
339
+ available = await resolveAdapter({ baseUrl: url, auth: trackerType, apiToken: token }).fetchStatuses();
340
+ }
341
+
310
342
  const lowerMap = new Map(available.map(n => [n.toLowerCase(), n]));
311
343
  stream.write('\x1b[A\r\x1b[2K');
312
344
 
@@ -316,13 +348,11 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
316
348
  toAdd.push(name);
317
349
  stream.write(` ${s.green('✔')} ${name}\n`);
318
350
  } else {
319
- // Case-insensitive exact match
320
351
  const exact = lowerMap.get(name.toLowerCase());
321
352
  if (exact) {
322
353
  stream.write(` ${s.yellow('~')} ${s.dim(name)} → ${s.cyan(exact)}\n`);
323
354
  toAdd.push(exact);
324
355
  } else {
325
- // Partial match: "QA" → "QA Testing"
326
356
  const partial = available.find(a =>
327
357
  a.toLowerCase().includes(name.toLowerCase()) ||
328
358
  name.toLowerCase().startsWith(a.toLowerCase().split(' ')[0])
@@ -343,9 +373,8 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
343
373
  }
344
374
  } catch {
345
375
  stream.write('\x1b[A\r\x1b[2K');
346
- // Jira unreachable — add without validation, deduped
347
- const toAddRaw = toValidate.filter(n => !existingLower.has(n.toLowerCase()));
348
- triageStatuses = [...currentStatuses, ...toAddRaw];
376
+ // Tracker unreachable — add without validation, deduped
377
+ triageStatuses = [...currentStatuses, ...toValidate.filter(n => !existingLower.has(n.toLowerCase()))];
349
378
  }
350
379
  }
351
380
  }
@@ -368,12 +397,7 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
368
397
  }
369
398
 
370
399
  // ── Save ───────────────────────────────────────────────────────────────────
371
- const updated = {
372
- ...profile,
373
- baseUrl: url,
374
- auth,
375
- triageStatuses,
376
- };
400
+ const updated = { ...profile, baseUrl: url, auth, triageStatuses };
377
401
  if (email) updated.email = email;
378
402
  if (ticketPrefixes.length > 0) updated.ticketPrefixes = ticketPrefixes;
379
403
  else delete updated.ticketPrefixes;
@@ -382,7 +406,6 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
382
406
  if (cacheTtl && cacheTtl !== DEFAULT_BRIEF_TTL) updated.cacheTtl = cacheTtl;
383
407
  else delete updated.cacheTtl;
384
408
 
385
- // Only write credentials file if the token actually changed
386
409
  const credData = (token !== existingToken)
387
410
  ? (auth === 'pat' ? { pat: token } : { apiToken: token })
388
411
  : {};