ticketlens 0.10.18 → 0.12.0

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/README.md CHANGED
@@ -79,7 +79,7 @@ Tip: `tl` works everywhere `ticketlens` does — running `tl`/`ticketlens config
79
79
  | `ticketlens profiles` | List all configured profiles (alias: `ticketlens ls`) |
80
80
  | `ticketlens delete <NAME>` | Remove a profile and its credentials (prompts `y/N` in TTY; use `--yes` in scripts/CI) |
81
81
 
82
- `init` collects: profile name, tracker type (Jira / GitHub Issues / Linear), URL or workspace, credentials (masked), and optional ticket prefixes, project paths, and triage statuses. On connection failure, a retry menu lets you fix credentials, URL, or skip — all inputs pre-populated. `config` is tracker-aware and always re-validates the connection after edits.
82
+ `init` collects: profile name, tracker type (Jira / GitHub Issues / Linear), URL or workspace, credentials (masked), and optional ticket prefixes, project paths, and triage statuses. On connection failure, a retry menu lets you fix credentials, URL, or skip — all inputs pre-populated. If your Jira instance sits behind a VPN and resolves to a private/internal address, you'll be asked to confirm you trust that connection before it's allowed through — a one-time confirmation, remembered per profile and scoped to that exact host (changing the URL asks again). `config` is tracker-aware and always re-validates the connection after edits.
83
83
 
84
84
  `config` uses merge semantics: new ticket prefixes and triage statuses are added to existing lists, never replaced. Partial matching resolves `QA` to `QA Testing` if that's the status in your Jira.
85
85
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.10.18",
3
+ "version": "0.12.0",
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": {
@@ -1,4 +1,4 @@
1
- <!-- jtb-skill-version: 0.10.18 -->
1
+ <!-- jtb-skill-version: 0.12.0 -->
2
2
  ---
3
3
  name: jtb
4
4
  description: Fetch a Jira ticket's full context (description, comments, linked issues, code references) and assemble a structured TicketBrief for implementation planning. Use when user types /jtb, mentions a Jira ticket key, or wants to plan work from a Jira ticket.
@@ -1134,7 +1134,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1134
1134
  const authHeader = buildAuthHeader(jiraEnv);
1135
1135
  const apiVersion = conn.auth === 'cloud' ? 3 : 2;
1136
1136
  const jiraOrigin = new URL(jiraEnv.JIRA_BASE_URL).origin;
1137
- const links = await fetchRemoteLinks(ticketKey, { env: jiraEnv, fetcher, apiVersion });
1137
+ const links = await fetchRemoteLinks(ticketKey, { env: jiraEnv, fetcher, apiVersion, allowPrivateIp: conn.allowPrivateIp });
1138
1138
  const MAX_CONFLUENCE_PAGES = 10;
1139
1139
  const safeLinks = links
1140
1140
  .filter(l => { try { return new URL(l.url).origin === jiraOrigin; } catch { return false; } })
@@ -1189,6 +1189,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1189
1189
  fetcher,
1190
1190
  noCache: args.includes('--no-cache'),
1191
1191
  onProgress: (msg) => process.stderr.write(msg + '\n'),
1192
+ allowPrivateIp: conn.allowPrivateIp,
1192
1193
  });
1193
1194
  const downloaded = ticket.localAttachments.filter(r => !r.skipped).length;
1194
1195
  const cached = ticket.localAttachments.filter(r => r.skipReason === 'cached').length;
@@ -11,9 +11,9 @@ export function createJiraAdapter(conn, { fetcher = globalThis.fetch } = {}) {
11
11
 
12
12
  return {
13
13
  type: 'jira',
14
- fetchTicket: (key, opts = {}) => fetchTicket(key, { env, fetcher, apiVersion, ...opts }),
15
- fetchCurrentUser: (opts = {}) => fetchCurrentUser({ env, fetcher, apiVersion, ...opts }),
16
- searchTickets: (query, opts = {}) => searchTickets(query, { env, fetcher, apiVersion, ...opts }),
17
- fetchStatuses: (opts = {}) => fetchStatuses({ env, fetcher, apiVersion, ...opts }),
14
+ fetchTicket: (key, opts = {}) => fetchTicket(key, { env, fetcher, apiVersion, allowPrivateIp: conn.allowPrivateIp, ...opts }),
15
+ fetchCurrentUser: (opts = {}) => fetchCurrentUser({ env, fetcher, apiVersion, allowPrivateIp: conn.allowPrivateIp, ...opts }),
16
+ searchTickets: (query, opts = {}) => searchTickets(query, { env, fetcher, apiVersion, allowPrivateIp: conn.allowPrivateIp, ...opts }),
17
+ fetchStatuses: (opts = {}) => fetchStatuses({ env, fetcher, apiVersion, allowPrivateIp: conn.allowPrivateIp, ...opts }),
18
18
  };
19
19
  }
@@ -20,10 +20,15 @@ const MAX_REDIRECT_HOPS = 2;
20
20
  * code-review remediation). Auth headers are only ever sent on the initial
21
21
  * request — every hop after that is auth-less, matching Jira Cloud's
22
22
  * presigned-URL CDN redirect model.
23
+ *
24
+ * `allowPrivateIp` only ever applies to the initial hop (the user's own
25
+ * trusted Jira origin). A redirect target is server-controlled — a
26
+ * categorically different trust domain — so it is never passed through,
27
+ * regardless of the caller's trust level.
23
28
  */
24
- async function followValidatedRedirects(initialUrl, fetcher, lookup, headers) {
29
+ async function followValidatedRedirects(initialUrl, fetcher, lookup, headers, allowPrivateIp = false) {
25
30
  let url = initialUrl;
26
- await validateResolvedHost(new URL(url).hostname, lookup);
31
+ await validateResolvedHost(new URL(url).hostname, lookup, allowPrivateIp);
27
32
  let response = await fetcher(url, { headers, redirect: 'manual' });
28
33
  let hops = 0;
29
34
  while (response.status >= 300 && response.status < 400) {
@@ -53,6 +58,7 @@ async function followValidatedRedirects(initialUrl, fetcher, lookup, headers) {
53
58
  * @param {string} opts.configDir Base config dir (default: ~/.ticketlens)
54
59
  * @param {boolean} opts.noCache Force re-download even if cached (default: false)
55
60
  * @param {Function} opts.onProgress Optional callback(msg: string) for progress lines
61
+ * @param {boolean} opts.allowPrivateIp Skip the private-IP DNS check on the initial hop only (default: false)
56
62
  *
57
63
  * @returns {Promise<Array<{filename, mimeType, size, localPath, skipped, skipReason, error}>>}
58
64
  */
@@ -64,6 +70,7 @@ export async function downloadAttachments(ticket, opts = {}) {
64
70
  configDir = DEFAULT_CONFIG_DIR,
65
71
  noCache = false,
66
72
  onProgress = null,
73
+ allowPrivateIp = false,
67
74
  } = opts;
68
75
 
69
76
  const attachments = (ticket.attachments ?? []).filter(a => a.content);
@@ -118,7 +125,7 @@ export async function downloadAttachments(ticket, opts = {}) {
118
125
  onProgress?.(` download ${a.filename}`);
119
126
  try {
120
127
  const headers = buildAuthHeader(env);
121
- const response = await followValidatedRedirects(a.content, fetcher, lookup, headers);
128
+ const response = await followValidatedRedirects(a.content, fetcher, lookup, headers, allowPrivateIp);
122
129
  if (!response.ok) throw new Error(`HTTP ${response.status} (${response.statusText})`);
123
130
  const buffer = await response.arrayBuffer();
124
131
  fs.writeFileSync(localPath, Buffer.from(buffer));
@@ -10,16 +10,17 @@ import { existsSync, mkdirSync } from 'node:fs';
10
10
  import { createStyler } from './ansi.mjs';
11
11
  import { createSession } from './banner.mjs';
12
12
  import { classifyError } from './error-classifier.mjs';
13
- import { fetchCurrentUser, fetchStatuses } from './jira-client.mjs';
13
+ import { fetchCurrentUser, fetchStatuses, fetchProjects } from './jira-client.mjs';
14
14
  import { resolveAdapter } from './resolve-adapter.mjs';
15
15
  import { loadProfiles, loadCredentials, saveProfile } from './profile-resolver.mjs';
16
16
  import { promptSelect } from './select-prompt.mjs';
17
17
  import { parseAge } from './cache-manager.mjs';
18
18
  import { DEFAULT_BRIEF_TTL } from './brief-cache.mjs';
19
- import { DEFAULT_CONFIG_DIR } from './config.mjs';
19
+ import { DEFAULT_CONFIG_DIR, hostnameOf } from './config.mjs';
20
20
  import { visLen, SERVER_AUTH_TYPES, promptText, promptSecret, promptYN } from './prompt-helpers.mjs';
21
21
  import { isLicensed } from './license.mjs';
22
22
  import { siteBase } from './api-utils.mjs';
23
+ import { pickTicketPrefixes, pickTriageStatuses, DEFAULT_TRIAGE_STATUSES } from './wizard-pickers.mjs';
23
24
 
24
25
  const RETRY_OPTIONS = [
25
26
  { label: 'Retry', sublabel: 'Try again — same credentials (e.g. VPN just connected)', value: 'retry' },
@@ -103,6 +104,10 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
103
104
  let email = profile.email || '';
104
105
  const existingToken = profileCreds.pat || profileCreds.apiToken || '';
105
106
  let token = existingToken;
107
+ // Trust is bound to the exact hostname it was granted for — never a flat
108
+ // per-profile boolean. Editing the URL to a different host must re-trigger
109
+ // the private-IP block and a fresh "Trust this connection?" prompt.
110
+ let trustedHostname = (profile.allowPrivateIp && hostnameOf(profile.baseUrl)) || null;
106
111
 
107
112
  // ── URL ───────────────────────────────────────────────────────────────────
108
113
  const urlTyped = await promptText(
@@ -158,6 +163,24 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
158
163
  let connected = false;
159
164
  let startFrom = 'test';
160
165
 
166
+ function isTrustedForCurrentUrl() {
167
+ return trustedHostname !== null && hostnameOf(url) === trustedHostname;
168
+ }
169
+
170
+ async function attemptConnection() {
171
+ if (isJira) {
172
+ const testEnv = {
173
+ JIRA_BASE_URL: url,
174
+ JIRA_EMAIL: email,
175
+ JIRA_API_TOKEN: auth !== 'pat' ? token : '',
176
+ JIRA_PAT: auth === 'pat' ? token : '',
177
+ };
178
+ await fetchCurrentUser({ env: testEnv, apiVersion: auth === 'cloud' ? 3 : 2, allowPrivateIp: isTrustedForCurrentUrl() });
179
+ } else {
180
+ await resolveAdapter({ baseUrl: url, auth: trackerType, apiToken: token }).fetchCurrentUser();
181
+ }
182
+ }
183
+
161
184
  setupLoop: while (true) {
162
185
  // Re-prompt URL + auth (on retry with 'url' option)
163
186
  if (startFrom === 'url') {
@@ -216,17 +239,7 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
216
239
  session.spin('Testing connection...');
217
240
 
218
241
  try {
219
- if (isJira) {
220
- const testEnv = {
221
- JIRA_BASE_URL: url,
222
- JIRA_EMAIL: email,
223
- JIRA_API_TOKEN: auth !== 'pat' ? token : '',
224
- JIRA_PAT: auth === 'pat' ? token : '',
225
- };
226
- await fetchCurrentUser({ env: testEnv, apiVersion: auth === 'cloud' ? 3 : 2 });
227
- } else {
228
- await resolveAdapter({ baseUrl: url, auth: trackerType, apiToken: token }).fetchCurrentUser();
229
- }
242
+ await attemptConnection();
230
243
  session.connected();
231
244
  connected = true;
232
245
  break setupLoop;
@@ -234,6 +247,15 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
234
247
  session.failed();
235
248
  const classified = classifyError(err, { baseUrl: url, profileName: target });
236
249
  session.footer(classified.message, 'error', classified.hint);
250
+
251
+ if (classified.privateIpBlocked) {
252
+ const trust = await promptYN('Trust this connection?', { stream });
253
+ if (trust) {
254
+ trustedHostname = hostnameOf(url);
255
+ startFrom = 'test';
256
+ continue setupLoop;
257
+ }
258
+ }
237
259
  }
238
260
 
239
261
  stream.write(`\n ${s.dim('What would you like to do?')}\n\n`);
@@ -251,19 +273,39 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
251
273
  // ── Optional settings ──────────────────────────────────────────────────────
252
274
  stream.write(`\n ${s.dim('──── Optional ────')}\n\n`);
253
275
 
254
- // Ticket prefixes
255
- const curPrefixes = (profile.ticketPrefixes || []).join(', ');
256
- const prefixInput = await promptText(
257
- s.dim('Ticket prefixes') + s.dim(curPrefixes ? ` [current: ${curPrefixes}]:` : ' (e.g. PROJ,OPS — press Enter to skip):'),
258
- { stream }
259
- );
260
- const existing = new Set(profile.ticketPrefixes || []);
261
- if (prefixInput) {
262
- for (const v of prefixInput.split(',').map(v => v.trim().toUpperCase()).filter(Boolean)) {
263
- existing.add(v);
276
+ // Shared env for post-connection Jira lookups (pickers + fallback validation)
277
+ const jiraEnv = {
278
+ JIRA_BASE_URL: url,
279
+ JIRA_EMAIL: email,
280
+ JIRA_API_TOKEN: auth !== 'pat' ? token : '',
281
+ JIRA_PAT: auth === 'pat' ? token : '',
282
+ };
283
+ const jiraApiVersion = auth === 'cloud' ? 3 : 2;
284
+
285
+ // Ticket prefixes — picked from the instance's project list with current
286
+ // values pre-checked (replace semantics: deselect removes); legacy add-only
287
+ // free text when the picker can't run (non-TTY, fetch failure, Esc, non-Jira)
288
+ let ticketPrefixes = isJira
289
+ ? await pickTicketPrefixes({
290
+ fetchProjects: () => fetchProjects({ env: jiraEnv, apiVersion: jiraApiVersion, allowPrivateIp: isTrustedForCurrentUrl() }),
291
+ current: profile.ticketPrefixes || [],
292
+ stream,
293
+ })
294
+ : null;
295
+ if (ticketPrefixes === null) {
296
+ const curPrefixes = (profile.ticketPrefixes || []).join(', ');
297
+ const prefixInput = await promptText(
298
+ s.dim('Ticket prefixes') + s.dim(curPrefixes ? ` [current: ${curPrefixes}]:` : ' (e.g. PROJ,OPS — press Enter to skip):'),
299
+ { stream }
300
+ );
301
+ const existing = new Set(profile.ticketPrefixes || []);
302
+ if (prefixInput) {
303
+ for (const v of prefixInput.split(',').map(v => v.trim().toUpperCase()).filter(Boolean)) {
304
+ existing.add(v);
305
+ }
264
306
  }
307
+ ticketPrefixes = [...existing];
265
308
  }
266
- const ticketPrefixes = [...existing];
267
309
 
268
310
  // Project path
269
311
  const home = homedir();
@@ -297,76 +339,83 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
297
339
  }
298
340
  }
299
341
 
300
- // ── Triage statuses (merge semantics) ────────────────────────────────────
342
+ // ── Triage statuses ────────────────────────────────────────────────────────
301
343
  const currentStatuses = profile.triageStatuses?.length
302
344
  ? profile.triageStatuses
303
- : ['In Progress', 'Code Review', 'QA'];
304
- const curStatusesStr = currentStatuses.join(', ');
345
+ : DEFAULT_TRIAGE_STATUSES;
346
+
347
+ // Picked from the instance's status list with current values pre-checked
348
+ // (replace semantics: deselect removes, stale entries kept unless unchecked);
349
+ // legacy add-only merge free text when the picker can't run
350
+ let triageStatuses = isJira
351
+ ? await pickTriageStatuses({
352
+ fetchStatuses: () => fetchStatuses({ env: jiraEnv, apiVersion: jiraApiVersion, allowPrivateIp: isTrustedForCurrentUrl() }),
353
+ current: currentStatuses,
354
+ stream,
355
+ })
356
+ : null;
305
357
 
306
- const statusInput = await promptText(
307
- s.dim('Add triage statuses') + s.dim(` [current: ${curStatusesStr} — Enter to keep]:`),
308
- { stream }
309
- );
358
+ if (triageStatuses === null) {
359
+ triageStatuses = currentStatuses;
360
+ const curStatusesStr = currentStatuses.join(', ');
310
361
 
311
- let triageStatuses = currentStatuses;
362
+ const statusInput = await promptText(
363
+ s.dim('Add triage statuses') + s.dim(` [current: ${curStatusesStr} — Enter to keep]:`),
364
+ { stream }
365
+ );
312
366
 
313
- if (statusInput) {
314
- const newEntries = statusInput.split(',').map(v => v.trim()).filter(Boolean);
315
- const existingLower = new Set(currentStatuses.map(n => n.toLowerCase()));
316
- const toValidate = newEntries.filter(n => !existingLower.has(n.toLowerCase()));
367
+ if (statusInput) {
368
+ const newEntries = statusInput.split(',').map(v => v.trim()).filter(Boolean);
369
+ const existingLower = new Set(currentStatuses.map(n => n.toLowerCase()));
370
+ const toValidate = newEntries.filter(n => !existingLower.has(n.toLowerCase()));
317
371
 
318
- if (toValidate.length > 0) {
319
- stream.write(` ${s.dim('Validating new statuses...')}\n`);
320
- try {
321
- let available;
322
- if (isJira) {
323
- const validateEnv = {
324
- JIRA_BASE_URL: url,
325
- JIRA_EMAIL: email,
326
- JIRA_API_TOKEN: auth !== 'pat' ? token : '',
327
- JIRA_PAT: auth === 'pat' ? token : '',
328
- };
329
- available = await fetchStatuses({ env: validateEnv, apiVersion: auth === 'cloud' ? 3 : 2 });
330
- } else {
331
- available = await resolveAdapter({ baseUrl: url, auth: trackerType, apiToken: token }).fetchStatuses();
332
- }
372
+ if (toValidate.length > 0) {
373
+ stream.write(` ${s.dim('Validating new statuses...')}\n`);
374
+ try {
375
+ let available;
376
+ if (isJira) {
377
+ available = await fetchStatuses({ env: jiraEnv, apiVersion: jiraApiVersion, allowPrivateIp: isTrustedForCurrentUrl() });
378
+ } else {
379
+ available = await resolveAdapter({ baseUrl: url, auth: trackerType, apiToken: token }).fetchStatuses();
380
+ }
333
381
 
334
- const lowerMap = new Map(available.map(n => [n.toLowerCase(), n]));
335
- stream.write('\x1b[A\r\x1b[2K');
382
+ const lowerMap = new Map(available.map(n => [n.toLowerCase(), n]));
383
+ stream.write('\x1b[A\r\x1b[2K');
336
384
 
337
- const toAdd = [];
338
- for (const name of toValidate) {
339
- if (available.includes(name)) {
340
- toAdd.push(name);
341
- stream.write(` ${s.green('✔')} ${name}\n`);
342
- } else {
343
- const exact = lowerMap.get(name.toLowerCase());
344
- if (exact) {
345
- stream.write(` ${s.yellow('~')} ${s.dim(name)} → ${s.cyan(exact)}\n`);
346
- toAdd.push(exact);
385
+ const toAdd = [];
386
+ for (const name of toValidate) {
387
+ if (available.includes(name)) {
388
+ toAdd.push(name);
389
+ stream.write(` ${s.green('✔')} ${name}\n`);
347
390
  } else {
348
- const partial = available.find(a =>
349
- a.toLowerCase().includes(name.toLowerCase()) ||
350
- name.toLowerCase().startsWith(a.toLowerCase().split(' ')[0])
351
- );
352
- if (partial) {
353
- stream.write(` ${s.yellow('~')} ${s.dim(name)} → ${s.cyan(partial)}\n`);
354
- toAdd.push(partial);
391
+ const exact = lowerMap.get(name.toLowerCase());
392
+ if (exact) {
393
+ stream.write(` ${s.yellow('~')} ${s.dim(name)} → ${s.cyan(exact)}\n`);
394
+ toAdd.push(exact);
355
395
  } else {
356
- stream.write(` ${s.red('✖')} ${name} ${s.dim('(not found — skipped)')}\n`);
396
+ const partial = available.find(a =>
397
+ a.toLowerCase().includes(name.toLowerCase()) ||
398
+ name.toLowerCase().startsWith(a.toLowerCase().split(' ')[0])
399
+ );
400
+ if (partial) {
401
+ stream.write(` ${s.yellow('~')} ${s.dim(name)} → ${s.cyan(partial)}\n`);
402
+ toAdd.push(partial);
403
+ } else {
404
+ stream.write(` ${s.red('✖')} ${name} ${s.dim('(not found — skipped)')}\n`);
405
+ }
357
406
  }
358
407
  }
359
408
  }
360
- }
361
409
 
362
- triageStatuses = [...currentStatuses, ...toAdd];
363
- if (toAdd.length > 0) {
364
- stream.write(` ${s.dim('Updated list:')} ${triageStatuses.map(n => s.cyan(n)).join(s.dim(', '))}\n`);
410
+ triageStatuses = [...currentStatuses, ...toAdd];
411
+ if (toAdd.length > 0) {
412
+ stream.write(` ${s.dim('Updated list:')} ${triageStatuses.map(n => s.cyan(n)).join(s.dim(', '))}\n`);
413
+ }
414
+ } catch {
415
+ stream.write('\x1b[A\r\x1b[2K');
416
+ // Tracker unreachable — add without validation, deduped
417
+ triageStatuses = [...currentStatuses, ...toValidate.filter(n => !existingLower.has(n.toLowerCase()))];
365
418
  }
366
- } catch {
367
- stream.write('\x1b[A\r\x1b[2K');
368
- // Tracker unreachable — add without validation, deduped
369
- triageStatuses = [...currentStatuses, ...toValidate.filter(n => !existingLower.has(n.toLowerCase()))];
370
419
  }
371
420
  }
372
421
  }
@@ -397,6 +446,8 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
397
446
  else delete updated.projectPaths;
398
447
  if (cacheTtl && cacheTtl !== DEFAULT_BRIEF_TTL) updated.cacheTtl = cacheTtl;
399
448
  else delete updated.cacheTtl;
449
+ if (isTrustedForCurrentUrl()) updated.allowPrivateIp = true;
450
+ else delete updated.allowPrivateIp;
400
451
 
401
452
  const credData = (token !== existingToken)
402
453
  ? (auth === 'pat' ? { pat: token } : { apiToken: token })
@@ -68,6 +68,12 @@ export function stripCr(str) {
68
68
  return str.replace(/\r/g, '');
69
69
  }
70
70
 
71
+ /** Parse a URL's hostname, or null if unparseable. Used to scope trust
72
+ * grants (e.g. allowPrivateIp) to the exact host they were confirmed for. */
73
+ export function hostnameOf(url) {
74
+ try { return new URL(url).hostname; } catch { return null; }
75
+ }
76
+
71
77
  /**
72
78
  * Build the env-like object expected by jira-client functions.
73
79
  * @param {{ baseUrl: string, pat?: string, email?: string, apiToken?: string }} conn
@@ -29,6 +29,7 @@ export async function testConnections({
29
29
  email: profile.email,
30
30
  apiToken: profileCreds.apiToken,
31
31
  pat: profileCreds.pat,
32
+ allowPrivateIp: profile.allowPrivateIp,
32
33
  };
33
34
 
34
35
  const session = createSession(
@@ -18,6 +18,15 @@ export function classifyError(err, { baseUrl, profileName } = {}) {
18
18
  const profile = profileName || 'your profile';
19
19
  const code = getNetworkCode(err);
20
20
 
21
+ if (err.code === 'PRIVATE_IP_BLOCKED') {
22
+ const address = err.blockedAddress ? ` (${err.blockedAddress})` : '';
23
+ return {
24
+ message: `${err.blockedHostname} resolves to a private address${address}`,
25
+ hint: "This is expected if you're on a VPN connecting to an on-prem Jira instance. It could also mean something is wrong.",
26
+ privateIpBlocked: true,
27
+ };
28
+ }
29
+
21
30
  // Network-level errors (fetch threw before getting an HTTP response)
22
31
  if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') {
23
32
  return {
@@ -9,15 +9,16 @@ import { existsSync, mkdirSync } from 'node:fs';
9
9
  import { createStyler } from './ansi.mjs';
10
10
  import { createSession } from './banner.mjs';
11
11
  import { classifyError } from './error-classifier.mjs';
12
- import { fetchCurrentUser, fetchStatuses } from './jira-client.mjs';
12
+ import { fetchCurrentUser, fetchStatuses, fetchProjects } from './jira-client.mjs';
13
13
  import { loadProfiles, saveProfile, saveDefault } from './profile-resolver.mjs';
14
14
  import { resolveAdapter } from './resolve-adapter.mjs';
15
15
  import { promptSelect } from './select-prompt.mjs';
16
16
  import { runSwitch } from './profile-switcher.mjs';
17
17
  import { renderWordmark } from './wordmark.mjs';
18
18
  import { printQuickStart } from './quick-start-panel.mjs';
19
- import { DEFAULT_CONFIG_DIR } from './config.mjs';
19
+ import { DEFAULT_CONFIG_DIR, hostnameOf } from './config.mjs';
20
20
  import { SERVER_AUTH_TYPES, promptText, promptSecret, promptYN } from './prompt-helpers.mjs';
21
+ import { pickTicketPrefixes, pickTriageStatuses, DEFAULT_TRIAGE_STATUSES } from './wizard-pickers.mjs';
21
22
 
22
23
  const RETRY_OPTIONS = [
23
24
  { label: 'Retry', sublabel: 'Try again — same credentials (e.g. VPN just connected)', value: 'retry' },
@@ -328,6 +329,12 @@ async function _run({ configDir, stream, s, showBanner, showQuickStart }) {
328
329
  let baseUrl = '', authType = '', email = '', token = '';
329
330
  let env = {}, apiVersion = 2;
330
331
  let startFrom = 'url';
332
+ // Trust is bound to the exact hostname it was granted for — never a flat
333
+ // per-run boolean. Choosing "Edit from URL" after granting trust for one
334
+ // host must re-trigger the private-IP block and a fresh confirmation
335
+ // for the new host, not silently inherit trust.
336
+ let trustedHostname = null;
337
+ const isTrustedForCurrentUrl = () => trustedHostname !== null && hostnameOf(baseUrl) === trustedHostname;
331
338
 
332
339
  setupLoop: while (true) {
333
340
  // ── URL + auth type ────────────────────────────────────────────────────
@@ -432,7 +439,7 @@ async function _run({ configDir, stream, s, showBanner, showQuickStart }) {
432
439
  session.spin('Testing connection...');
433
440
 
434
441
  try {
435
- await fetchCurrentUser({ env, apiVersion });
442
+ await fetchCurrentUser({ env, apiVersion, allowPrivateIp: isTrustedForCurrentUrl() });
436
443
  session.connected();
437
444
  connected = true;
438
445
  break setupLoop;
@@ -440,6 +447,15 @@ async function _run({ configDir, stream, s, showBanner, showQuickStart }) {
440
447
  session.failed();
441
448
  const classified = classifyError(err, { baseUrl, profileName });
442
449
  session.footer(classified.message, 'error', classified.hint);
450
+
451
+ if (classified.privateIpBlocked) {
452
+ const trust = await promptYN('Trust this connection?', { stream });
453
+ if (trust) {
454
+ trustedHostname = hostnameOf(baseUrl);
455
+ startFrom = 'retry';
456
+ continue setupLoop;
457
+ }
458
+ }
443
459
  }
444
460
 
445
461
  // ── Retry options ──────────────────────────────────────────────────────
@@ -453,13 +469,20 @@ async function _run({ configDir, stream, s, showBanner, showQuickStart }) {
453
469
  // ── Optional settings ───────────────────────────────────────────────────
454
470
  stream.write(`\n ${s.dim('──── Optional (press Enter to skip) ────')}\n\n`);
455
471
 
456
- // Ticket prefixes
457
- const prefixInput = await promptText(
458
- s.dim('Ticket prefixes') + s.dim(' (e.g. PROJ,OPS):'), { stream }
459
- );
460
- const ticketPrefixes = prefixInput
461
- ? prefixInput.split(',').map(v => v.trim().toUpperCase()).filter(Boolean)
462
- : [];
472
+ // Ticket prefixes — picked from the instance's project list; free-text
473
+ // fallback when the picker can't run (non-TTY, fetch failure, Esc)
474
+ let ticketPrefixes = await pickTicketPrefixes({
475
+ fetchProjects: () => fetchProjects({ env, apiVersion, allowPrivateIp: isTrustedForCurrentUrl() }),
476
+ stream,
477
+ });
478
+ if (ticketPrefixes === null) {
479
+ const prefixInput = await promptText(
480
+ s.dim('Ticket prefixes') + s.dim(' (e.g. PROJ,OPS):'), { stream }
481
+ );
482
+ ticketPrefixes = prefixInput
483
+ ? prefixInput.split(',').map(v => v.trim().toUpperCase()).filter(Boolean)
484
+ : [];
485
+ }
463
486
 
464
487
  // Project path (single — used for auto-profile detection from cwd)
465
488
  const home = homedir();
@@ -492,45 +515,55 @@ async function _run({ configDir, stream, s, showBanner, showQuickStart }) {
492
515
  }
493
516
  }
494
517
 
495
- // Triage statuses — validate against Jira's actual status names
496
- const DEFAULT_TRIAGE = 'In Progress, Code Review, QA';
497
- const statusInput = await promptText(
498
- s.dim('Triage statuses') + s.dim(` [default: ${DEFAULT_TRIAGE}]:`), { stream }
499
- );
500
- let triageStatuses = statusInput
501
- ? statusInput.split(',').map(v => v.trim()).filter(Boolean)
502
- : DEFAULT_TRIAGE.split(',').map(v => v.trim());
518
+ // Triage statuses — picked from the instance's status list with the
519
+ // defaults pre-checked (stale defaults dropped, not user data); free-text
520
+ // + validate fallback when the picker can't run (non-TTY, fetch failure, Esc)
521
+ let triageStatuses = await pickTriageStatuses({
522
+ fetchStatuses: () => fetchStatuses({ env, apiVersion, allowPrivateIp: isTrustedForCurrentUrl() }),
523
+ current: DEFAULT_TRIAGE_STATUSES,
524
+ preserveMissing: false,
525
+ stream,
526
+ });
527
+ if (triageStatuses === null) {
528
+ const DEFAULT_TRIAGE = DEFAULT_TRIAGE_STATUSES.join(', ');
529
+ const statusInput = await promptText(
530
+ s.dim('Triage statuses') + s.dim(` [default: ${DEFAULT_TRIAGE}]:`), { stream }
531
+ );
532
+ triageStatuses = statusInput
533
+ ? statusInput.split(',').map(v => v.trim()).filter(Boolean)
534
+ : [...DEFAULT_TRIAGE_STATUSES];
503
535
 
504
- stream.write(` ${s.dim('Validating statuses...')}\n`);
505
- try {
506
- const available = await fetchStatuses({ env, apiVersion });
507
- const lowerMap = new Map(available.map(n => [n.toLowerCase(), n]));
508
- stream.write('\x1b[A\r\x1b[2K'); // clear "Validating..." line
509
- const corrected = [];
510
- let hasIssues = false;
511
- for (const name of triageStatuses) {
512
- if (available.includes(name)) {
513
- corrected.push(name);
514
- } else {
515
- const match = lowerMap.get(name.toLowerCase());
516
- if (match) {
517
- stream.write(` ${s.yellow('~')} ${s.dim(name)} → ${s.cyan(match)}\n`);
518
- corrected.push(match);
519
- hasIssues = true;
536
+ stream.write(` ${s.dim('Validating statuses...')}\n`);
537
+ try {
538
+ const available = await fetchStatuses({ env, apiVersion, allowPrivateIp: isTrustedForCurrentUrl() });
539
+ const lowerMap = new Map(available.map(n => [n.toLowerCase(), n]));
540
+ stream.write('\x1b[A\r\x1b[2K'); // clear "Validating..." line
541
+ const corrected = [];
542
+ let hasIssues = false;
543
+ for (const name of triageStatuses) {
544
+ if (available.includes(name)) {
545
+ corrected.push(name);
520
546
  } else {
521
- stream.write(` ${s.red('✖')} ${name} ${s.dim('(not found in this Jira instance)')}\n`);
522
- hasIssues = true;
547
+ const match = lowerMap.get(name.toLowerCase());
548
+ if (match) {
549
+ stream.write(` ${s.yellow('~')} ${s.dim(name)} → ${s.cyan(match)}\n`);
550
+ corrected.push(match);
551
+ hasIssues = true;
552
+ } else {
553
+ stream.write(` ${s.red('✖')} ${name} ${s.dim('(not found in this Jira instance)')}\n`);
554
+ hasIssues = true;
555
+ }
523
556
  }
524
557
  }
558
+ if (!hasIssues) {
559
+ stream.write(` ${s.green('✔')} Statuses validated\n`);
560
+ } else if (corrected.length > 0) {
561
+ stream.write(` ${s.dim('Using:')} ${corrected.map(n => s.cyan(n)).join(s.dim(', '))}\n`);
562
+ }
563
+ triageStatuses = corrected;
564
+ } catch {
565
+ stream.write('\x1b[A\r\x1b[2K'); // clear silently if Jira call fails
525
566
  }
526
- if (!hasIssues) {
527
- stream.write(` ${s.green('✔')} Statuses validated\n`);
528
- } else if (corrected.length > 0) {
529
- stream.write(` ${s.dim('Using:')} ${corrected.map(n => s.cyan(n)).join(s.dim(', '))}\n`);
530
- }
531
- triageStatuses = corrected;
532
- } catch {
533
- stream.write('\x1b[A\r\x1b[2K'); // clear silently if Jira call fails
534
567
  }
535
568
 
536
569
  // ── Save ──────────────────────────────────────────────────────────────
@@ -540,6 +573,7 @@ async function _run({ configDir, stream, s, showBanner, showQuickStart }) {
540
573
  ...(email ? { email } : {}),
541
574
  ...(ticketPrefixes.length > 0 ? { ticketPrefixes } : {}),
542
575
  ...(projectPaths.length > 0 ? { projectPaths } : {}),
576
+ ...(isTrustedForCurrentUrl() ? { allowPrivateIp: true } : {}),
543
577
  triageStatuses,
544
578
  };
545
579
  const credData = authType === 'pat' ? { pat: token } : { apiToken: token };
@@ -125,7 +125,14 @@ function normalizeHostname(hostname) {
125
125
  return hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '');
126
126
  }
127
127
 
128
- export function validateBaseUrl(url) {
128
+ /**
129
+ * @param {string} url
130
+ * @param {boolean} [allowPrivateIp] - skip the private/internal blocklist check
131
+ * for THIS hostname. Set only via explicit interactive user confirmation
132
+ * during setup, persisted per-profile — never from synced/network data
133
+ * (see team-jira-sync.mjs). HTTPS enforcement is never skippable.
134
+ */
135
+ export function validateBaseUrl(url, allowPrivateIp = false) {
129
136
  let parsed;
130
137
  try { parsed = new URL(url); } catch {
131
138
  throw new Error(`JIRA_BASE_URL is not a valid URL: ${url}`);
@@ -133,9 +140,14 @@ export function validateBaseUrl(url) {
133
140
  if (parsed.protocol !== 'https:') {
134
141
  throw new Error(`JIRA_BASE_URL must use HTTPS (got ${parsed.protocol})`);
135
142
  }
143
+ if (allowPrivateIp) return;
136
144
  const hostname = normalizeHostname(parsed.hostname);
137
145
  if (BLOCKED_PATTERNS.some(re => re.test(hostname))) {
138
- throw new Error(`JIRA_BASE_URL hostname is blocked (${hostname})`);
146
+ const err = new Error(`JIRA_BASE_URL hostname is blocked (${hostname})`);
147
+ err.code = 'PRIVATE_IP_BLOCKED';
148
+ err.blockedHostname = hostname;
149
+ err.blockedAddress = null;
150
+ throw err;
139
151
  }
140
152
  }
141
153
 
@@ -189,9 +201,17 @@ const dnsValidationCaches = new WeakMap();
189
201
  * `lookup` is null when the fetcher is mocked (tests) — no socket opens, so
190
202
  * there is nothing to protect. Production always uses globalThis.fetch, which
191
203
  * always gets the real resolver (see defaultLookupFor).
204
+ *
205
+ * `allowPrivateIp` skips the lookup entirely (not just the block) when a
206
+ * profile has an explicit, user-confirmed trust exception for this exact
207
+ * hostname — set only during interactive setup, never from synced/network
208
+ * data. Skipping the resolution outright (rather than resolving-then-
209
+ * ignoring) avoids the DNS round-trip and sidesteps any question of how the
210
+ * per-lookup-fn cache should key on a flag that no real caller varies
211
+ * concurrently for the same hostname.
192
212
  */
193
- export async function validateResolvedHost(hostname, lookup) {
194
- if (!lookup) return;
213
+ export async function validateResolvedHost(hostname, lookup, allowPrivateIp = false) {
214
+ if (!lookup || allowPrivateIp) return;
195
215
  let cache = dnsValidationCaches.get(lookup);
196
216
  if (!cache) {
197
217
  cache = new Map();
@@ -202,7 +222,11 @@ export async function validateResolvedHost(hostname, lookup) {
202
222
  const addresses = await lookup(hostname, { all: true });
203
223
  for (const { address } of addresses) {
204
224
  if (BLOCKED_PATTERNS.some(re => re.test(address))) {
205
- throw new Error(`Hostname ${hostname} resolves to a blocked address (${address}) — refusing to connect`);
225
+ const err = new Error(`Hostname ${hostname} resolves to a blocked address (${address}) — refusing to connect`);
226
+ err.code = 'PRIVATE_IP_BLOCKED';
227
+ err.blockedHostname = hostname;
228
+ err.blockedAddress = address;
229
+ throw err;
206
230
  }
207
231
  }
208
232
  })();
@@ -223,8 +247,8 @@ export async function validateResolvedHost(hostname, lookup) {
223
247
  * Error message carries the redirect target's HOSTNAME only — Location values
224
248
  * can contain presigned tokens in path/query.
225
249
  */
226
- export async function guardedFetch(url, fetchOpts, { fetcher, lookup }) {
227
- await validateResolvedHost(new URL(url).hostname, lookup);
250
+ export async function guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp = false }) {
251
+ await validateResolvedHost(new URL(url).hostname, lookup, allowPrivateIp);
228
252
  const response = await fetcher(url, { ...fetchOpts, redirect: 'manual' });
229
253
  if (response.status >= 300 && response.status < 400) {
230
254
  let target = 'unknown host';
@@ -248,15 +272,15 @@ export function buildAuthHeader(env) {
248
272
  }
249
273
 
250
274
  export async function fetchCurrentUser(opts = {}) {
251
- const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000 } = opts;
252
- validateBaseUrl(env.JIRA_BASE_URL);
275
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
276
+ validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
253
277
  const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
254
278
  const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
255
279
 
256
280
  const url = `${baseUrl}/rest/api/${apiVersion}/myself`;
257
281
  const fetchOpts = { headers };
258
282
  if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
259
- const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
283
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
260
284
 
261
285
  if (!response.ok) {
262
286
  const err = new Error(`Jira API error ${response.status} fetching current user`);
@@ -274,15 +298,15 @@ export async function fetchCurrentUser(opts = {}) {
274
298
  }
275
299
 
276
300
  export async function fetchStatuses(opts = {}) {
277
- const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000 } = opts;
278
- validateBaseUrl(env.JIRA_BASE_URL);
301
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
302
+ validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
279
303
  const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
280
304
  const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
281
305
 
282
306
  const url = `${baseUrl}/rest/api/${apiVersion}/status`;
283
307
  const fetchOpts = { headers };
284
308
  if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
285
- const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
309
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
286
310
 
287
311
  if (!response.ok) {
288
312
  const err = new Error(`Jira API error ${response.status} fetching statuses`);
@@ -294,9 +318,53 @@ export async function fetchStatuses(opts = {}) {
294
318
  return [...new Set(raw.map(s => s.name))].sort();
295
319
  }
296
320
 
321
+ /**
322
+ * Lists projects visible to the authenticated user as [{key, name}], key-sorted.
323
+ * v2 (Server/DC): GET /project returns the full array in one response.
324
+ * v3 (Cloud): GET /project/search is paginated — walked until isLast, capped
325
+ * at MAX_PROJECT_PAGES to bound the walk against a misbehaving server.
326
+ */
327
+ export async function fetchProjects(opts = {}) {
328
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
329
+ validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
330
+ const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
331
+ const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
332
+
333
+ async function getJson(url) {
334
+ const fetchOpts = { headers };
335
+ if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
336
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
337
+ if (!response.ok) {
338
+ const err = new Error(`Jira API error ${response.status} fetching projects`);
339
+ err.status = response.status;
340
+ throw err;
341
+ }
342
+ return response.json();
343
+ }
344
+
345
+ const projects = [];
346
+ if (apiVersion >= 3) {
347
+ const MAX_PROJECT_PAGES = 10;
348
+ const PAGE_SIZE = 50;
349
+ let startAt = 0;
350
+ for (let page = 0; page < MAX_PROJECT_PAGES; page++) {
351
+ const params = new URLSearchParams({ startAt: String(startAt), maxResults: String(PAGE_SIZE) });
352
+ const raw = await getJson(`${baseUrl}/rest/api/3/project/search?${params}`);
353
+ const values = raw.values ?? [];
354
+ for (const p of values) projects.push({ key: p.key, name: p.name ?? '' });
355
+ if (raw.isLast || values.length === 0) break;
356
+ startAt += values.length;
357
+ }
358
+ } else {
359
+ const raw = await getJson(`${baseUrl}/rest/api/2/project`);
360
+ for (const p of raw) projects.push({ key: p.key, name: p.name ?? '' });
361
+ }
362
+ return projects.sort((a, b) => a.key.localeCompare(b.key));
363
+ }
364
+
297
365
  export async function searchTickets(jql, opts = {}) {
298
- const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), maxResults = 50, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false } = opts;
299
- validateBaseUrl(env.JIRA_BASE_URL);
366
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), maxResults = 50, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false, allowPrivateIp = false } = opts;
367
+ validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
300
368
  const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
301
369
  const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
302
370
 
@@ -307,7 +375,7 @@ export async function searchTickets(jql, opts = {}) {
307
375
  const url = `${baseUrl}${endpoint}?${params}`;
308
376
  const fetchOpts = { headers };
309
377
  if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
310
- const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
378
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
311
379
 
312
380
  if (!response.ok) {
313
381
  let detail = '';
@@ -323,15 +391,15 @@ export async function searchTickets(jql, opts = {}) {
323
391
  }
324
392
 
325
393
  export async function fetchRemoteLinks(ticketKey, opts = {}) {
326
- const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000 } = opts;
327
- validateBaseUrl(env.JIRA_BASE_URL);
394
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
395
+ validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
328
396
  const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
329
397
  const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}/remotelink`;
330
398
 
331
399
  const fetchOpts = { headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' } };
332
400
  if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
333
401
 
334
- const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
402
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
335
403
  if (!response.ok) return [];
336
404
 
337
405
  const raw = await response.json();
@@ -341,8 +409,8 @@ export async function fetchRemoteLinks(ticketKey, opts = {}) {
341
409
  }
342
410
 
343
411
  export async function fetchTicket(ticketKey, opts = {}) {
344
- const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), depth = 1, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false, _visited = new Set(), _currentDepth = 0 } = opts;
345
- validateBaseUrl(env.JIRA_BASE_URL);
412
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), depth = 1, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false, allowPrivateIp = false, _visited = new Set(), _currentDepth = 0 } = opts;
413
+ validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
346
414
  const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
347
415
  const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
348
416
 
@@ -350,7 +418,7 @@ export async function fetchTicket(ticketKey, opts = {}) {
350
418
  const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}${expand}`;
351
419
  const fetchOpts = { headers };
352
420
  if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
353
- const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
421
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
354
422
 
355
423
  if (!response.ok) {
356
424
  const err = new Error(`Jira API error ${response.status} fetching ${ticketKey}`);
@@ -233,6 +233,7 @@ export function resolveConnection(ticketKey, opts = {}) {
233
233
  ticketPrefixes: profile.ticketPrefixes || null,
234
234
  attentionRules: profile.attentionRules ?? null,
235
235
  staleRule: profile.staleRule ?? null,
236
+ allowPrivateIp: profile.allowPrivateIp || false,
236
237
  source: 'profile',
237
238
  profileName: profile.name,
238
239
  };
@@ -118,6 +118,7 @@ export async function runSwitch({ configDir = DEFAULT_CONFIG_DIR, stream = proce
118
118
  JIRA_PAT: profileCreds.pat || '',
119
119
  };
120
120
  const apiVersion = profile.auth === 'cloud' ? 3 : 2;
121
+ const allowPrivateIp = profile.allowPrivateIp;
121
122
 
122
123
  let frame = 0;
123
124
  const spinLine = () => ` ${s.cyan(SPINNER_FRAMES[frame])} Connecting to ${s.bold(chosen.name)}...`;
@@ -129,7 +130,7 @@ export async function runSwitch({ configDir = DEFAULT_CONFIG_DIR, stream = proce
129
130
  }, 80);
130
131
 
131
132
  try {
132
- await fetchCurrentUser({ env, apiVersion });
133
+ await fetchCurrentUser({ env, apiVersion, allowPrivateIp });
133
134
  clearInterval(timer);
134
135
  stream.write('\r\x1b[2K\x1b[A\r\x1b[2K');
135
136
  await saveDefault(chosen.name, configDir);
@@ -30,9 +30,8 @@ export const visLen = (str) => str.replace(ANSI_RE, '').length;
30
30
  * then drain. Call this — and await it — before starting any new raw-mode
31
31
  * prompt.
32
32
  */
33
- export function flushStdin() {
33
+ export function flushStdin(stdin = process.stdin) {
34
34
  return new Promise((resolve) => {
35
- const stdin = process.stdin;
36
35
  if (typeof stdin.read !== 'function' || typeof stdin.resume !== 'function') {
37
36
  resolve();
38
37
  return;
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Reusable raw-mode selector primitive and simple promptSelect helper.
3
- * Used by profile-picker.mjs, profile-switcher.mjs, and init-wizard.mjs.
2
+ * Reusable raw-mode selector primitives: single-select and multi-select.
3
+ * Used by profile-picker.mjs, profile-switcher.mjs, init-wizard.mjs,
4
+ * config-wizard.mjs, and wizard-pickers.mjs.
4
5
  */
5
6
 
6
7
  import { createStyler } from './ansi.mjs';
@@ -16,10 +17,15 @@ import { flushStdin } from './prompt-helpers.mjs';
16
17
  * @param {number} [opts.initialIndex=0]
17
18
  * @param {(index: number) => number} opts.renderFn
18
19
  * @param {NodeJS.WriteStream} [opts.stream=process.stderr]
20
+ * @param {NodeJS.ReadStream} [opts.stdin=process.stdin] injectable for tests
21
+ * @param {(key: string, index: number) => 'veto'|boolean} [opts.onKey]
22
+ * Hook for extra keys. Called for Enter before submit — return 'veto' to
23
+ * swallow it and keep the selector open. For any other unhandled key,
24
+ * return truthy to re-render. Falsy = key ignored.
19
25
  * @returns {Promise<number|null>} selected index, or null if cancelled
20
26
  */
21
- export function runRawSelect({ count, initialIndex = 0, renderFn, stream = process.stderr }) {
22
- if (!stream.isTTY || !process.stdin.setRawMode) return Promise.resolve(null);
27
+ export function runRawSelect({ count, initialIndex = 0, renderFn, stream = process.stderr, stdin = process.stdin, onKey }) {
28
+ if (!stream.isTTY || !stdin.setRawMode) return Promise.resolve(null);
23
29
 
24
30
  let selected = initialIndex;
25
31
  let lineCount = 0;
@@ -34,8 +40,7 @@ export function runRawSelect({ count, initialIndex = 0, renderFn, stream = proce
34
40
  }
35
41
 
36
42
  stream.write('\x1b[?25l');
37
- return flushStdin().then(() => new Promise((resolve) => {
38
- const stdin = process.stdin;
43
+ return flushStdin(stdin).then(() => new Promise((resolve) => {
39
44
  const wasRaw = stdin.isRaw;
40
45
 
41
46
  function cleanup() {
@@ -52,7 +57,11 @@ export function runRawSelect({ count, initialIndex = 0, renderFn, stream = proce
52
57
  }
53
58
  if (key === '\x1b[A' && selected > 0) { selected--; render(); return; }
54
59
  if (key === '\x1b[B' && selected < count - 1) { selected++; render(); return; }
55
- if (key === '\r' || key === '\n') { cleanup(); erase(); resolve(selected); return; }
60
+ if (key === '\r' || key === '\n') {
61
+ if (onKey && onKey(key, selected) === 'veto') { render(); return; }
62
+ cleanup(); erase(); resolve(selected); return;
63
+ }
64
+ if (onKey && onKey(key, selected)) { render(); return; }
56
65
  }
57
66
 
58
67
  stdin.setRawMode(true);
@@ -105,3 +114,88 @@ export function promptSelect(items, opts = {}) {
105
114
 
106
115
  return runRawSelect({ count: items.length, initialIndex, renderFn, stream });
107
116
  }
117
+
118
+ /**
119
+ * Arrow-key multi-select with checkboxes and a scrolling viewport.
120
+ * Space toggles the highlighted row, `a` toggles all, Enter confirms,
121
+ * Esc cancels (returns null — callers treat that as "enter manually").
122
+ *
123
+ * @param {Array<{label: string}>} items
124
+ * @param {object} [opts]
125
+ * @param {number[]} [opts.initialSelected=[]] pre-checked item indices
126
+ * @param {number} [opts.minSelected=0] Enter is vetoed below this count
127
+ * @param {number} [opts.maxVisible=10] viewport height for long lists
128
+ * @param {string} [opts.hint]
129
+ * @param {NodeJS.WriteStream} [opts.stream]
130
+ * @param {NodeJS.ReadStream} [opts.stdin] injectable for tests
131
+ * @returns {Promise<number[]|null>} checked indices in list order, or null if cancelled
132
+ */
133
+ export function promptMultiSelect(items, opts = {}) {
134
+ const {
135
+ stream = process.stderr,
136
+ stdin = process.stdin,
137
+ hint = '↑/↓ move Space toggle a all Enter confirm Esc manual entry',
138
+ initialSelected = [],
139
+ minSelected = 0,
140
+ maxVisible = 10,
141
+ } = opts;
142
+
143
+ const s = createStyler({ isTTY: stream.isTTY });
144
+
145
+ if (!stream.isTTY || !stdin.setRawMode) {
146
+ for (const item of items) stream.write(` ${s.cyan('›')} ${item.label}\n`);
147
+ return Promise.resolve(null);
148
+ }
149
+
150
+ const checked = new Set(initialSelected.filter(i => i >= 0 && i < items.length));
151
+ let scrollTop = 0;
152
+ let flash = '';
153
+
154
+ function renderFn(selected) {
155
+ if (selected >= scrollTop + maxVisible) scrollTop = selected - maxVisible + 1;
156
+ if (selected < scrollTop) scrollTop = selected;
157
+ const visibleEnd = Math.min(scrollTop + maxVisible, items.length);
158
+
159
+ const lines = [];
160
+ for (let i = scrollTop; i < visibleEnd; i++) {
161
+ const isSelected = i === selected;
162
+ const marker = isSelected ? s.blue('❯') : ' ';
163
+ const box = checked.has(i) ? s.green('◉') : s.dim('○');
164
+ const label = isSelected ? s.bold(s.blue(items[i].label)) : items[i].label;
165
+ lines.push(` ${marker} ${box} ${label}`);
166
+ }
167
+ if (items.length > maxVisible) {
168
+ lines.push(` ${s.dim(`${scrollTop + 1}-${visibleEnd} of ${items.length}`)}`);
169
+ }
170
+ lines.push('');
171
+ lines.push(` ${s.dim(hint)}${flash ? ' ' + s.yellow(flash) : ''}`);
172
+ stream.write(lines.join('\n') + '\n');
173
+ return lines.length;
174
+ }
175
+
176
+ function onKey(key, selected) {
177
+ if (key === ' ') {
178
+ flash = '';
179
+ if (checked.has(selected)) checked.delete(selected);
180
+ else checked.add(selected);
181
+ return true;
182
+ }
183
+ if (key === 'a' || key === 'A') {
184
+ flash = '';
185
+ if (checked.size === items.length) checked.clear();
186
+ else for (let i = 0; i < items.length; i++) checked.add(i);
187
+ return true;
188
+ }
189
+ if (key === '\r' || key === '\n') {
190
+ if (checked.size < minSelected) {
191
+ flash = `select at least ${minSelected}`;
192
+ return 'veto';
193
+ }
194
+ return false;
195
+ }
196
+ return false;
197
+ }
198
+
199
+ return runRawSelect({ count: items.length, renderFn, stream, stdin, onKey })
200
+ .then(result => (result === null ? null : [...checked].sort((a, b) => a - b)));
201
+ }
@@ -9,7 +9,7 @@ import { join } from 'node:path';
9
9
  import { readCliToken } from './cli-auth.mjs';
10
10
  import { saveProfile, loadProfiles } from './profile-resolver.mjs';
11
11
  import { apiBase } from './api-utils.mjs';
12
- import { DEFAULT_CONFIG_DIR } from './config.mjs';
12
+ import { DEFAULT_CONFIG_DIR, hostnameOf } from './config.mjs';
13
13
 
14
14
  const META_FILE = 'team-jira-meta.json';
15
15
 
@@ -78,11 +78,23 @@ function teamConfigToCliProfile(apiData) {
78
78
 
79
79
  /**
80
80
  * Writes team config to the named profile without touching credentials.
81
+ * Carries forward `allowPrivateIp` from the existing LOCAL profile only —
82
+ * never from server data (see teamConfigToCliProfile) — and only when the
83
+ * synced baseUrl resolves to the SAME hostname the trust was granted for.
84
+ * A sync that changes the host (legitimate re-point, or a compromised/
85
+ * malicious sync response) must never inherit trust for a different,
86
+ * unconfirmed host — the member has to re-confirm via the wizard.
81
87
  * Returns { error, message } on failure — never throws.
82
88
  */
83
89
  async function applyTeamJiraConfig(groupName, profileData, configDir) {
84
90
  try {
85
- await saveProfile(groupName, profileData, null, configDir);
91
+ const existing = loadProfiles(configDir)?.profiles?.[groupName];
92
+ const sameHost = existing?.baseUrl && profileData.baseUrl &&
93
+ hostnameOf(existing.baseUrl) === hostnameOf(profileData.baseUrl);
94
+ const merged = (existing?.allowPrivateIp && sameHost)
95
+ ? { ...profileData, allowPrivateIp: true }
96
+ : profileData;
97
+ await saveProfile(groupName, merged, null, configDir);
86
98
  return { ok: true };
87
99
  } catch (e) {
88
100
  return { error: 'save-failed', message: e.message };
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Shared prefix/status picker steps for init-wizard.mjs and config-wizard.mjs.
3
+ * Live-fetched multi-select over the connected Jira instance's projects and
4
+ * statuses. Every picker returns string[] on confirm, or null when the picker
5
+ * cannot run (non-TTY, fetch failure, empty server list) or the user hits Esc
6
+ * — callers treat null as "use the legacy free-text prompt".
7
+ */
8
+
9
+ import { createStyler } from './ansi.mjs';
10
+ import { promptMultiSelect } from './select-prompt.mjs';
11
+
12
+ export const DEFAULT_TRIAGE_STATUSES = ['In Progress', 'Code Review', 'QA'];
13
+
14
+ /**
15
+ * Builds picker rows from server values plus the user's current selections.
16
+ * Matching is case-insensitive; rows echo the server's canonical casing so a
17
+ * confirm fixes case drift in the profile. Current values missing from the
18
+ * server are appended as marked, pre-checked rows when preserveMissing is on
19
+ * (edit flows — user data is never silently dropped) and skipped when off
20
+ * (init flows — stale defaults are not user data).
21
+ */
22
+ function buildRows({ serverValues, labels, current, preserveMissing, s }) {
23
+ const values = [...serverValues];
24
+ const rows = serverValues.map((v, i) => ({ label: labels[i] }));
25
+ const serverLower = new Set(serverValues.map(v => v.toLowerCase()));
26
+
27
+ const staleSeen = new Set(); // legacy free-text merge never deduped within itself
28
+ for (const cur of current) {
29
+ const lower = cur.toLowerCase();
30
+ if (preserveMissing && !serverLower.has(lower) && !staleSeen.has(lower)) {
31
+ staleSeen.add(lower);
32
+ values.push(cur);
33
+ rows.push({ label: `${cur} ${s.dim('(not on server)')}` });
34
+ }
35
+ }
36
+
37
+ const currentLower = new Set(current.map(c => c.toLowerCase()));
38
+ const initialSelected = values
39
+ .map((v, i) => (currentLower.has(v.toLowerCase()) ? i : -1))
40
+ .filter(i => i >= 0);
41
+
42
+ return { values, rows, initialSelected };
43
+ }
44
+
45
+ /**
46
+ * Fetches via fetchFn behind a clearable progress line. Returns the list, or
47
+ * null after printing a fallback warning (raw error details stay internal —
48
+ * the connection already tested fine, so this is a transient/permission issue).
49
+ */
50
+ async function fetchForPicker(fetchFn, { what, stream, s }) {
51
+ stream.write(` ${s.dim(`Fetching ${what}...`)}\n`);
52
+ try {
53
+ const list = await fetchFn();
54
+ stream.write('\x1b[A\r\x1b[2K');
55
+ return list;
56
+ } catch {
57
+ stream.write('\x1b[A\r\x1b[2K');
58
+ stream.write(` ${s.yellow('~')} Could not fetch ${what} — enter manually\n`);
59
+ return null;
60
+ }
61
+ }
62
+
63
+ function writeConfirmation(chosen, { stream, s }) {
64
+ stream.write(` ${s.green('✔')} ${chosen.length ? chosen.map(v => s.cyan(v)).join(s.dim(', ')) : s.dim('none')}\n`);
65
+ }
66
+
67
+ /**
68
+ * Multi-select over the instance's projects; returns the chosen project keys.
69
+ *
70
+ * @param {object} opts
71
+ * @param {() => Promise<Array<{key: string, name: string}>>} opts.fetchProjects
72
+ * pre-bound by the caller — env/apiVersion/allowPrivateIp trust stays there
73
+ * @param {string[]} [opts.current=[]] pre-checked prefixes (edit flow)
74
+ * @param {boolean} [opts.preserveMissing=true]
75
+ * @returns {Promise<string[]|null>} chosen keys, or null → free-text fallback
76
+ */
77
+ export async function pickTicketPrefixes({ fetchProjects, current = [], preserveMissing = true, stream = process.stderr, stdin = process.stdin } = {}) {
78
+ const s = createStyler({ isTTY: stream.isTTY });
79
+ if (!stream.isTTY || !stdin.setRawMode) return null;
80
+
81
+ const projects = await fetchForPicker(fetchProjects, { what: 'projects', stream, s });
82
+ if (projects === null || projects.length === 0) return null;
83
+
84
+ const { values, rows, initialSelected } = buildRows({
85
+ serverValues: projects.map(p => p.key),
86
+ labels: projects.map(p => (p.name ? `${p.key} ${s.dim('—')} ${p.name}` : p.key)),
87
+ current,
88
+ preserveMissing,
89
+ s,
90
+ });
91
+
92
+ stream.write(` ${s.dim('Ticket prefixes')} ${s.dim('(projects on this instance)')}\n\n`);
93
+ const picked = await promptMultiSelect(rows, { initialSelected, stream, stdin });
94
+ if (picked === null) return null;
95
+
96
+ const chosen = picked.map(i => values[i]);
97
+ writeConfirmation(chosen, { stream, s });
98
+ return chosen;
99
+ }
100
+
101
+ /**
102
+ * Multi-select over the instance's status names; at least one required.
103
+ *
104
+ * @param {object} opts
105
+ * @param {() => Promise<string[]>} opts.fetchStatuses pre-bound by the caller
106
+ * @param {string[]} [opts.current=[]] pre-checked statuses
107
+ * @param {boolean} [opts.preserveMissing=true]
108
+ * @returns {Promise<string[]|null>} chosen names, or null → free-text fallback
109
+ */
110
+ export async function pickTriageStatuses({ fetchStatuses, current = [], preserveMissing = true, stream = process.stderr, stdin = process.stdin } = {}) {
111
+ const s = createStyler({ isTTY: stream.isTTY });
112
+ if (!stream.isTTY || !stdin.setRawMode) return null;
113
+
114
+ const available = await fetchForPicker(fetchStatuses, { what: 'statuses', stream, s });
115
+ if (available === null || available.length === 0) return null;
116
+
117
+ const { values, rows, initialSelected } = buildRows({
118
+ serverValues: available,
119
+ labels: available,
120
+ current,
121
+ preserveMissing,
122
+ s,
123
+ });
124
+
125
+ stream.write(` ${s.dim('Triage statuses')} ${s.dim('(statuses on this instance)')}\n\n`);
126
+ const picked = await promptMultiSelect(rows, { initialSelected, minSelected: 1, stream, stdin });
127
+ if (picked === null) return null;
128
+
129
+ const chosen = picked.map(i => values[i]);
130
+ writeConfirmation(chosen, { stream, s });
131
+ return chosen;
132
+ }