ticketlens 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
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,33 +148,21 @@ 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
- const tokenChanged = token !== existingToken;
134
-
135
- // ── Connection test (only when something changed) ─────────────────────────
136
- const connectionChanged = url !== profile.baseUrl
137
- || auth !== profile.auth
138
- || email !== (profile.email || '')
139
- || tokenChanged;
140
155
 
141
- let connected = !connectionChanged;
156
+ // ── Connection test (always) ──────────────────────────────────────────────
157
+ let connected = false;
158
+ let startFrom = 'test';
142
159
 
143
- if (connectionChanged) {
144
- let startFrom = 'test';
145
-
146
- setupLoop: while (true) {
147
- // Re-prompt URL + auth
148
- if (startFrom === 'url') {
160
+ setupLoop: while (true) {
161
+ // Re-prompt URL + auth (on retry with 'url' option)
162
+ if (startFrom === 'url') {
149
163
  stream.write('\n');
150
164
  const reTyped = await promptText(
151
- s.dim('Jira URL') + s.dim(` [current: ${url}]:`),
165
+ s.dim(urlLabel) + s.dim(` [current: ${url}]:`),
152
166
  { stream, defaultValue: url }
153
167
  );
154
168
  if (reTyped !== url) {
@@ -157,21 +171,24 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
157
171
  : `https://${reTyped.replace(/\/$/, '')}`;
158
172
  if (url !== reTyped) stream.write(` ${s.dim('○')} Interpreted as ${url}\n`);
159
173
  }
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;
174
+
175
+ if (isJira) {
176
+ const reCloud = /\.atlassian\.net(\/|$)/i.test(url);
177
+ if (reCloud) {
178
+ auth = 'cloud';
179
+ stream.write(` ${s.green('✔')} Jira Cloud detected — using email + API token\n\n`);
180
+ } else if (auth === 'cloud') {
181
+ stream.write(`\n ${s.dim('Auth type:')}\n\n`);
182
+ const idx = await promptSelect(SERVER_AUTH_TYPES, { stream, hint: '↑/↓ select Enter confirm' });
183
+ if (idx !== null) auth = SERVER_AUTH_TYPES[idx].value;
184
+ }
168
185
  }
169
186
  startFrom = 'creds';
170
187
  }
171
188
 
172
189
  // Re-prompt email + token (pre-populated)
173
190
  if (startFrom === 'creds') {
174
- if (auth === 'cloud' || auth === 'basic') {
191
+ if (isJira && (auth === 'cloud' || auth === 'basic')) {
175
192
  const eHint = email ? s.dim(` [current: ${email}]`) : '';
176
193
  const eLabel = (auth === 'cloud' ? s.dim('Email') : s.dim('Username')) + eHint + s.dim(':');
177
194
  email = await promptText(eLabel, {
@@ -185,24 +202,30 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
185
202
  });
186
203
  }
187
204
  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(':');
205
+ const tLabel = s.dim(getTokenLabel(trackerType, auth)) + tHint + s.dim(':');
189
206
  token = await promptSecret(tLabel, { stream, existingValue: token });
190
207
  }
191
208
 
192
209
  // 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 });
210
+ const session = createSession(
211
+ { baseUrl: url, profileName: target, email: email || undefined, pat: auth === 'pat' ? token : undefined },
212
+ { stream },
213
+ );
201
214
  stream.write('\n');
202
215
  session.spin('Testing connection...');
203
216
 
204
217
  try {
205
- await fetchCurrentUser({ env: testEnv, apiVersion: testVersion });
218
+ if (isJira) {
219
+ const testEnv = {
220
+ JIRA_BASE_URL: url,
221
+ JIRA_EMAIL: email,
222
+ JIRA_API_TOKEN: auth !== 'pat' ? token : '',
223
+ JIRA_PAT: auth === 'pat' ? token : '',
224
+ };
225
+ await fetchCurrentUser({ env: testEnv, apiVersion: auth === 'cloud' ? 3 : 2 });
226
+ } else {
227
+ await resolveAdapter({ baseUrl: url, auth: trackerType, apiToken: token }).fetchCurrentUser();
228
+ }
206
229
  session.connected();
207
230
  connected = true;
208
231
  break setupLoop;
@@ -218,11 +241,10 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
218
241
  startFrom = RETRY_OPTIONS[retryIdx].value;
219
242
  }
220
243
 
221
- if (!connected) {
222
- stream.write(`\n ${s.yellow('○')} Connection changes discarded. ${s.dim('Run')} ${s.cyan('ticketlens config')} ${s.dim('again to retry.')}\n\n`);
223
- process.exitCode = 1;
224
- return;
225
- }
244
+ if (!connected) {
245
+ stream.write(`\n ${s.yellow('○')} Connection changes discarded. ${s.dim('Run')} ${s.cyan('ticketlens config')} ${s.dim('again to retry.')}\n\n`);
246
+ process.exitCode = 1;
247
+ return;
226
248
  }
227
249
 
228
250
  // ── Optional settings ──────────────────────────────────────────────────────
@@ -242,7 +264,7 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
242
264
  }
243
265
  const ticketPrefixes = [...existing];
244
266
 
245
- // Project path (single — used for auto-profile detection from cwd)
267
+ // Project path
246
268
  const home = homedir();
247
269
  const cwd = process.cwd();
248
270
  const cwdDisplay = cwd.startsWith(home) ? '~' + cwd.slice(home.length) : cwd;
@@ -275,8 +297,6 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
275
297
  }
276
298
 
277
299
  // ── 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
300
  const currentStatuses = profile.triageStatuses?.length
281
301
  ? profile.triageStatuses
282
302
  : ['In Progress', 'Code Review', 'QA'];
@@ -292,21 +312,24 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
292
312
  if (statusInput) {
293
313
  const newEntries = statusInput.split(',').map(v => v.trim()).filter(Boolean);
294
314
  const existingLower = new Set(currentStatuses.map(n => n.toLowerCase()));
295
- // Only validate statuses that aren't already in the list
296
315
  const toValidate = newEntries.filter(n => !existingLower.has(n.toLowerCase()));
297
316
 
298
317
  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
318
  stream.write(` ${s.dim('Validating new statuses...')}\n`);
308
319
  try {
309
- const available = await fetchStatuses({ env: validateEnv, apiVersion: validateApiVersion });
320
+ let available;
321
+ if (isJira) {
322
+ const validateEnv = {
323
+ JIRA_BASE_URL: url,
324
+ JIRA_EMAIL: email,
325
+ JIRA_API_TOKEN: auth !== 'pat' ? token : '',
326
+ JIRA_PAT: auth === 'pat' ? token : '',
327
+ };
328
+ available = await fetchStatuses({ env: validateEnv, apiVersion: auth === 'cloud' ? 3 : 2 });
329
+ } else {
330
+ available = await resolveAdapter({ baseUrl: url, auth: trackerType, apiToken: token }).fetchStatuses();
331
+ }
332
+
310
333
  const lowerMap = new Map(available.map(n => [n.toLowerCase(), n]));
311
334
  stream.write('\x1b[A\r\x1b[2K');
312
335
 
@@ -316,13 +339,11 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
316
339
  toAdd.push(name);
317
340
  stream.write(` ${s.green('✔')} ${name}\n`);
318
341
  } else {
319
- // Case-insensitive exact match
320
342
  const exact = lowerMap.get(name.toLowerCase());
321
343
  if (exact) {
322
344
  stream.write(` ${s.yellow('~')} ${s.dim(name)} → ${s.cyan(exact)}\n`);
323
345
  toAdd.push(exact);
324
346
  } else {
325
- // Partial match: "QA" → "QA Testing"
326
347
  const partial = available.find(a =>
327
348
  a.toLowerCase().includes(name.toLowerCase()) ||
328
349
  name.toLowerCase().startsWith(a.toLowerCase().split(' ')[0])
@@ -343,9 +364,8 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
343
364
  }
344
365
  } catch {
345
366
  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];
367
+ // Tracker unreachable — add without validation, deduped
368
+ triageStatuses = [...currentStatuses, ...toValidate.filter(n => !existingLower.has(n.toLowerCase()))];
349
369
  }
350
370
  }
351
371
  }
@@ -368,12 +388,7 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
368
388
  }
369
389
 
370
390
  // ── Save ───────────────────────────────────────────────────────────────────
371
- const updated = {
372
- ...profile,
373
- baseUrl: url,
374
- auth,
375
- triageStatuses,
376
- };
391
+ const updated = { ...profile, baseUrl: url, auth, triageStatuses };
377
392
  if (email) updated.email = email;
378
393
  if (ticketPrefixes.length > 0) updated.ticketPrefixes = ticketPrefixes;
379
394
  else delete updated.ticketPrefixes;
@@ -382,7 +397,6 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
382
397
  if (cacheTtl && cacheTtl !== DEFAULT_BRIEF_TTL) updated.cacheTtl = cacheTtl;
383
398
  else delete updated.cacheTtl;
384
399
 
385
- // Only write credentials file if the token actually changed
386
400
  const credData = (token !== existingToken)
387
401
  ? (auth === 'pat' ? { pat: token } : { apiToken: token })
388
402
  : {};