ticketlens 0.10.4 → 0.11.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.
Files changed (30) hide show
  1. package/README.md +3 -1
  2. package/bin/ticketlens.mjs +47 -117
  3. package/package.json +3 -2
  4. package/scripts/postinstall.mjs +34 -0
  5. package/skills/jtb/SKILL.md +2 -2
  6. package/skills/jtb/scripts/fetch-ticket.mjs +2 -1
  7. package/skills/jtb/scripts/lib/adapters/jira-adapter.mjs +4 -4
  8. package/skills/jtb/scripts/lib/alias-status.mjs +73 -0
  9. package/skills/jtb/scripts/lib/api-utils.mjs +1 -1
  10. package/skills/jtb/scripts/lib/attachment-downloader.mjs +10 -3
  11. package/skills/jtb/scripts/lib/cli.mjs +1 -1
  12. package/skills/jtb/scripts/lib/config-wizard.mjs +36 -13
  13. package/skills/jtb/scripts/lib/config.mjs +29 -9
  14. package/skills/jtb/scripts/lib/connection-tester.mjs +54 -0
  15. package/skills/jtb/scripts/lib/error-classifier.mjs +9 -0
  16. package/skills/jtb/scripts/lib/help.mjs +8 -6
  17. package/skills/jtb/scripts/lib/init-wizard.mjs +38 -41
  18. package/skills/jtb/scripts/lib/jira-client.mjs +46 -22
  19. package/skills/jtb/scripts/lib/login-flow.mjs +98 -0
  20. package/skills/jtb/scripts/lib/onboarding.mjs +254 -0
  21. package/skills/jtb/scripts/lib/profile-resolver.mjs +1 -0
  22. package/skills/jtb/scripts/lib/profile-switcher.mjs +2 -1
  23. package/skills/jtb/scripts/lib/prompt-helpers.mjs +41 -2
  24. package/skills/jtb/scripts/lib/quick-start-panel.mjs +32 -0
  25. package/skills/jtb/scripts/lib/run-stats.mjs +2 -1
  26. package/skills/jtb/scripts/lib/select-prompt.mjs +4 -3
  27. package/skills/jtb/scripts/lib/setup-state.mjs +35 -0
  28. package/skills/jtb/scripts/lib/sync.mjs +48 -0
  29. package/skills/jtb/scripts/lib/team-jira-sync.mjs +14 -2
  30. package/skills/jtb/scripts/lib/wordmark.mjs +69 -0
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Tests every configured profile's connection, one at a time with a spinner
3
+ * per profile — the same pattern init-wizard.mjs/config-wizard.mjs already
4
+ * use for a single profile, just looped across all of them.
5
+ */
6
+
7
+ import { createSession } from './banner.mjs';
8
+ import { classifyError } from './error-classifier.mjs';
9
+ import { resolveAdapter } from './resolve-adapter.mjs';
10
+ import { loadProfiles, loadCredentials } from './profile-resolver.mjs';
11
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
12
+
13
+ export async function testConnections({
14
+ configDir = DEFAULT_CONFIG_DIR,
15
+ stream = process.stderr,
16
+ resolveAdapterFn = resolveAdapter,
17
+ } = {}) {
18
+ const config = loadProfiles(configDir);
19
+ const names = config ? Object.keys(config.profiles) : [];
20
+ const creds = loadCredentials(configDir);
21
+ const results = [];
22
+
23
+ for (const name of names) {
24
+ const profile = config.profiles[name];
25
+ const profileCreds = creds[name] || {};
26
+ const conn = {
27
+ baseUrl: profile.baseUrl,
28
+ auth: profile.auth,
29
+ email: profile.email,
30
+ apiToken: profileCreds.apiToken,
31
+ pat: profileCreds.pat,
32
+ allowPrivateIp: profile.allowPrivateIp,
33
+ };
34
+
35
+ const session = createSession(
36
+ { baseUrl: conn.baseUrl, profileName: name, email: conn.email || undefined, pat: conn.auth === 'pat' ? conn.pat : undefined },
37
+ { stream },
38
+ );
39
+ session.spin('Testing connection...');
40
+
41
+ try {
42
+ await resolveAdapterFn(conn).fetchCurrentUser();
43
+ session.connected();
44
+ results.push({ name, ok: true });
45
+ } catch (err) {
46
+ session.failed();
47
+ const classified = classifyError(err, { baseUrl: conn.baseUrl, profileName: name });
48
+ session.footer(classified.message, 'error', classified.hint);
49
+ results.push({ name, ok: false, error: classified.message });
50
+ }
51
+ }
52
+
53
+ return { results, failedCount: results.filter(r => !r.ok).length };
54
+ }
@@ -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 {
@@ -3,12 +3,11 @@
3
3
  */
4
4
 
5
5
  import { createStyler } from './ansi.mjs';
6
- import { getVersion } from './config.mjs';
6
+ import { renderWordmark } from './wordmark.mjs';
7
7
 
8
8
  export function printHelp({ stream = process.stdout } = {}) {
9
9
  const isTTY = stream.isTTY;
10
10
  const s = createStyler({ isTTY });
11
- const v = getVersion();
12
11
 
13
12
  // Column targets (visible chars):
14
13
  // USAGE: command portion = 36, so descriptions always start at the same column
@@ -18,12 +17,11 @@ export function printHelp({ stream = process.stdout } = {}) {
18
17
  // ANSI codes (s.cyan, s.dim) add invisible bytes — they do NOT affect visible width.
19
18
 
20
19
  const lines = [
21
- '',
22
- ` ${s.bold(s.brand('◆ TicketLens'))} ${s.dim(`v${v}`)}`,
23
- ` ${s.dim('Stop tab-switching. Start building.')}`,
24
20
  '',
25
21
  ` ${s.bold('USAGE')}`,
26
22
  '',
23
+ ` ${s.brand('tl')} ${s.dim('<anything>')} Shorthand for ${s.brand('ticketlens')}`,
24
+ '',
27
25
  // Alignment targets (visible chars before description):
28
26
  // Groups 1–2 (auth/setup): column 40 (command portion ≤ 24)
29
27
  // Group 3 (daily use): column 42 (longest: "review [--branch=BRANCH]" = 39)
@@ -56,6 +54,10 @@ export function printHelp({ stream = process.stdout } = {}) {
56
54
  ` ${s.brand('ticketlens')} cloud-keys ${s.dim('[add|remove|list|test]')} Manage your encrypted AI provider keys ${s.dim('[Pro]')}`,
57
55
  ` ${s.brand('ticketlens')} update-skill ${s.dim('[--dry-run]')} Update /jtb skill in Claude Code and other AI assistants`,
58
56
  '',
57
+ ` ${s.bold('GLOBAL OPTIONS')}`,
58
+ '',
59
+ ` ${s.brand('--no-input')} Force non-interactive behavior even in a terminal`,
60
+ '',
59
61
  ` ${s.bold('FETCH OPTIONS')}`,
60
62
  '',
61
63
  // visible widths: "--profile=NAME"=14, "--depth=N"=9, "--plain"=7, "--styled"=8,
@@ -125,7 +127,7 @@ export function printHelp({ stream = process.stdout } = {}) {
125
127
  '',
126
128
  ];
127
129
 
128
- stream.write(lines.join('\n') + '\n');
130
+ stream.write(renderWordmark({ stream }) + lines.join('\n') + '\n');
129
131
  }
130
132
 
131
133
  export function printFetchHelp({ stream = process.stdout } = {}) {
@@ -14,8 +14,10 @@ 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
- import { DEFAULT_CONFIG_DIR } from './config.mjs';
18
- import { visLen, SERVER_AUTH_TYPES, promptText, promptSecret, promptYN } from './prompt-helpers.mjs';
17
+ import { renderWordmark } from './wordmark.mjs';
18
+ import { printQuickStart } from './quick-start-panel.mjs';
19
+ import { DEFAULT_CONFIG_DIR, hostnameOf } from './config.mjs';
20
+ import { SERVER_AUTH_TYPES, promptText, promptSecret, promptYN } from './prompt-helpers.mjs';
19
21
 
20
22
  const RETRY_OPTIONS = [
21
23
  { label: 'Retry', sublabel: 'Try again — same credentials (e.g. VPN just connected)', value: 'retry' },
@@ -51,7 +53,7 @@ async function probeProtocol(host, { stream, s }) {
51
53
 
52
54
  // ── Main wizard ───────────────────────────────────────────────────────────────
53
55
 
54
- export async function run({ configDir = DEFAULT_CONFIG_DIR } = {}) {
56
+ export async function run({ configDir = DEFAULT_CONFIG_DIR, showBanner = true, showQuickStart = true } = {}) {
55
57
  const stream = process.stderr;
56
58
  const s = createStyler({ isTTY: stream.isTTY });
57
59
 
@@ -65,27 +67,22 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR } = {}) {
65
67
  process.on('SIGINT', onSigint);
66
68
 
67
69
  try {
68
- await _run({ configDir, stream, s });
70
+ await _run({ configDir, stream, s, showBanner, showQuickStart });
69
71
  } finally {
70
72
  process.removeListener('SIGINT', onSigint);
71
73
  }
72
74
  }
73
75
 
74
- async function _run({ configDir, stream, s }) {
75
- // Welcome box
76
- const headerLines = [
77
- `${s.bold(s.cyan('◆ TicketLens'))} — Setup Wizard`,
78
- `${s.dim("Let's configure your tracker connection.")}`,
79
- ];
80
- const innerWidth = headerLines.reduce((max, l) => Math.max(max, visLen(l)), 0) + 4;
81
- const bc = s.cyan;
82
- stream.write('\n');
83
- stream.write(bc('╭' + '─'.repeat(innerWidth) + '╮') + '\n');
84
- for (const line of headerLines) {
85
- const pad = innerWidth - visLen(line) - 1;
86
- stream.write(bc('│') + ' ' + line + ' '.repeat(Math.max(0, pad)) + bc('│') + '\n');
76
+ async function _run({ configDir, stream, s, showBanner, showQuickStart }) {
77
+ // Welcome banner — same wordmark as --help/--version, so a brand-new user's
78
+ // very first output looks like the same product (Phase 1's renderWordmark()
79
+ // was built reusable for exactly this; not duplicating the art). Suppressed
80
+ // when invoked from the onboarding hub, which already showed its own banner
81
+ // — otherwise the user sees it twice in the fresh-install flow.
82
+ if (showBanner) {
83
+ stream.write(renderWordmark({ stream }));
84
+ stream.write(`\n${s.dim("Let's configure your tracker connection.")}\n`);
87
85
  }
88
- stream.write(bc('╰' + '─'.repeat(innerWidth) + '╯') + '\n');
89
86
 
90
87
  let addedCount = 0;
91
88
  let addAnother = true;
@@ -331,6 +328,12 @@ async function _run({ configDir, stream, s }) {
331
328
  let baseUrl = '', authType = '', email = '', token = '';
332
329
  let env = {}, apiVersion = 2;
333
330
  let startFrom = 'url';
331
+ // Trust is bound to the exact hostname it was granted for — never a flat
332
+ // per-run boolean. Choosing "Edit from URL" after granting trust for one
333
+ // host must re-trigger the private-IP block and a fresh confirmation
334
+ // for the new host, not silently inherit trust.
335
+ let trustedHostname = null;
336
+ const isTrustedForCurrentUrl = () => trustedHostname !== null && hostnameOf(baseUrl) === trustedHostname;
334
337
 
335
338
  setupLoop: while (true) {
336
339
  // ── URL + auth type ────────────────────────────────────────────────────
@@ -435,7 +438,7 @@ async function _run({ configDir, stream, s }) {
435
438
  session.spin('Testing connection...');
436
439
 
437
440
  try {
438
- await fetchCurrentUser({ env, apiVersion });
441
+ await fetchCurrentUser({ env, apiVersion, allowPrivateIp: isTrustedForCurrentUrl() });
439
442
  session.connected();
440
443
  connected = true;
441
444
  break setupLoop;
@@ -443,6 +446,15 @@ async function _run({ configDir, stream, s }) {
443
446
  session.failed();
444
447
  const classified = classifyError(err, { baseUrl, profileName });
445
448
  session.footer(classified.message, 'error', classified.hint);
449
+
450
+ if (classified.privateIpBlocked) {
451
+ const trust = await promptYN('Trust this connection?', { stream });
452
+ if (trust) {
453
+ trustedHostname = hostnameOf(baseUrl);
454
+ startFrom = 'retry';
455
+ continue setupLoop;
456
+ }
457
+ }
446
458
  }
447
459
 
448
460
  // ── Retry options ──────────────────────────────────────────────────────
@@ -506,7 +518,7 @@ async function _run({ configDir, stream, s }) {
506
518
 
507
519
  stream.write(` ${s.dim('Validating statuses...')}\n`);
508
520
  try {
509
- const available = await fetchStatuses({ env, apiVersion });
521
+ const available = await fetchStatuses({ env, apiVersion, allowPrivateIp: isTrustedForCurrentUrl() });
510
522
  const lowerMap = new Map(available.map(n => [n.toLowerCase(), n]));
511
523
  stream.write('\x1b[A\r\x1b[2K'); // clear "Validating..." line
512
524
  const corrected = [];
@@ -543,6 +555,7 @@ async function _run({ configDir, stream, s }) {
543
555
  ...(email ? { email } : {}),
544
556
  ...(ticketPrefixes.length > 0 ? { ticketPrefixes } : {}),
545
557
  ...(projectPaths.length > 0 ? { projectPaths } : {}),
558
+ ...(isTrustedForCurrentUrl() ? { allowPrivateIp: true } : {}),
546
559
  triageStatuses,
547
560
  };
548
561
  const credData = authType === 'pat' ? { pat: token } : { apiToken: token };
@@ -575,25 +588,9 @@ async function _run({ configDir, stream, s }) {
575
588
  const profileWord = addedCount === 1 ? '1 profile' : `${addedCount} profiles`;
576
589
  stream.write(`\n ${s.green('✔')} ${profileWord} configured.\n\n`);
577
590
 
578
- // Quick-start panel
579
- const cmds = [
580
- ['ticketlens triage', 'Scan your assigned tickets'],
581
- ['ticketlens <TICKET-KEY>', 'Fetch a specific ticket'],
582
- ['ticketlens switch', 'Switch active profile'],
583
- ['ticketlens --help', 'Full command reference'],
584
- ];
585
- const cmdWidth = cmds.reduce((max, [c]) => Math.max(max, c.length), 0);
586
- const cmdRows = cmds.map(([cmd, desc]) =>
587
- ` ${s.bold(s.cyan(cmd.padEnd(cmdWidth)))} ${s.dim(desc)}`
588
- );
589
- const QTITLE = ' Quick start ';
590
- const contentWidth = cmdRows.reduce((max, r) => Math.max(max, visLen(r)), 0);
591
- const qWidth = Math.max(contentWidth + 2, QTITLE.length + 4);
592
- const qPad = (r) => ' ' + r + ' '.repeat(Math.max(0, qWidth - visLen(r) - 1));
593
- const qTitleFill = qWidth - 1 - QTITLE.length;
594
- stream.write(bc('╭') + bc('─') + s.bold(s.cyan(QTITLE)) + bc('─'.repeat(Math.max(0, qTitleFill))) + bc('╮') + '\n');
595
- stream.write(bc('│') + qPad('') + bc('│') + '\n');
596
- for (const r of cmdRows) stream.write(bc('│') + qPad(r) + bc('│') + '\n');
597
- stream.write(bc('│') + qPad('') + bc('│') + '\n');
598
- stream.write(bc('╰') + bc('─'.repeat(qWidth)) + bc('╯') + '\n\n');
591
+ // Suppressed when invoked from the onboarding hub, which owns showing this
592
+ // exactly once after its own menu loop exits — otherwise the user sees it twice.
593
+ if (showQuickStart) {
594
+ printQuickStart({ stream, s });
595
+ }
599
596
  }
@@ -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`);
@@ -295,8 +319,8 @@ export async function fetchStatuses(opts = {}) {
295
319
  }
296
320
 
297
321
  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);
322
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), maxResults = 50, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false, allowPrivateIp = false } = opts;
323
+ validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
300
324
  const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
301
325
  const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
302
326
 
@@ -307,7 +331,7 @@ export async function searchTickets(jql, opts = {}) {
307
331
  const url = `${baseUrl}${endpoint}?${params}`;
308
332
  const fetchOpts = { headers };
309
333
  if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
310
- const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
334
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
311
335
 
312
336
  if (!response.ok) {
313
337
  let detail = '';
@@ -323,15 +347,15 @@ export async function searchTickets(jql, opts = {}) {
323
347
  }
324
348
 
325
349
  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);
350
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
351
+ validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
328
352
  const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
329
353
  const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}/remotelink`;
330
354
 
331
355
  const fetchOpts = { headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' } };
332
356
  if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
333
357
 
334
- const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
358
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
335
359
  if (!response.ok) return [];
336
360
 
337
361
  const raw = await response.json();
@@ -341,8 +365,8 @@ export async function fetchRemoteLinks(ticketKey, opts = {}) {
341
365
  }
342
366
 
343
367
  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);
368
+ 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;
369
+ validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
346
370
  const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
347
371
  const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
348
372
 
@@ -350,7 +374,7 @@ export async function fetchTicket(ticketKey, opts = {}) {
350
374
  const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}${expand}`;
351
375
  const fetchOpts = { headers };
352
376
  if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
353
- const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
377
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
354
378
 
355
379
  if (!response.ok) {
356
380
  const err = new Error(`Jira API error ${response.status} fetching ${ticketKey}`);
@@ -0,0 +1,98 @@
1
+ /**
2
+ * ticketlens login — connect the CLI to a TicketLens account.
3
+ * Extracted out of bin/ticketlens.mjs so the onboarding hub can reuse it
4
+ * without duplicating the browser/manual flow or token verification.
5
+ */
6
+
7
+ import { createStyler } from './ansi.mjs';
8
+ import { promptSecret } from './prompt-helpers.mjs';
9
+ import { browserLogin } from './browser-login.mjs';
10
+ import { saveCliToken } from './cli-auth.mjs';
11
+ import { getApiBase, getConsoleBase } from './sync.mjs';
12
+ import { applyTeamConfigOnLogin } from './team-jira-sync.mjs';
13
+
14
+ export async function runLogin({
15
+ manual = false,
16
+ stream = process.stderr,
17
+ showSyncHint = true,
18
+ browserLoginFn = browserLogin,
19
+ fetchFn = globalThis.fetch,
20
+ promptSecretFn = promptSecret,
21
+ saveCliTokenFn = saveCliToken,
22
+ applyTeamConfigOnLoginFn = applyTeamConfigOnLogin,
23
+ } = {}) {
24
+ const s = createStyler({ isTTY: stream.isTTY });
25
+
26
+ let token;
27
+
28
+ if (manual) {
29
+ // ── manual paste flow (CI / headless environments) ──────────────────
30
+ stream.write(`\n ${s.bold('TicketLens Login')}\n`);
31
+ stream.write(` ${s.dim('─'.repeat(44))}\n`);
32
+ stream.write(` ${s.dim(`Generate a CLI token at ${s.cyan(`${getConsoleBase()}/console/account`)}`)}\n`);
33
+ stream.write(` ${s.dim('then paste it below.')}\n\n`);
34
+
35
+ token = await promptSecretFn(`CLI Token ${s.dim('(tl_…)')}:`, { stream });
36
+ if (!token.startsWith('tl_')) {
37
+ stream.write(` ${s.red('✖')} Token must start with ${s.dim('tl_')}\n`);
38
+ process.exitCode = 1;
39
+ return;
40
+ }
41
+ } else {
42
+ // ── browser flow (default) ────────────────────────────────────────
43
+ stream.write(`\n ${s.bold('TicketLens Login')}\n`);
44
+ stream.write(` ${s.dim('─'.repeat(44))}\n`);
45
+ stream.write(` Opening browser to authorize…\n\n`);
46
+ stream.write(` ${s.dim('○ Waiting for authorization (120s)…')}\n`);
47
+
48
+ try {
49
+ token = await browserLoginFn();
50
+ } catch (err) {
51
+ const cancelled = err.message === 'Authorization cancelled';
52
+ stream.write(`\x1b[A\r\x1b[2K ${s.red('✖')} ${cancelled ? 'Login cancelled.' : err.message}\n`);
53
+ if (!cancelled) {
54
+ stream.write(`\n ${s.dim(`Try ${s.cyan('ticketlens login --manual')} to paste a token instead.`)}\n\n`);
55
+ }
56
+ process.exitCode = cancelled ? 0 : 1;
57
+ return;
58
+ }
59
+ }
60
+
61
+ // ── verify token against API (both flows) ─────────────────────────
62
+ stream.write(`\n ${s.dim('○ Verifying token…')}\n`);
63
+ let res;
64
+ try {
65
+ res = await fetchFn(`${getApiBase()}/v1/profiles`, {
66
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
67
+ signal: AbortSignal.timeout(15000),
68
+ });
69
+ } catch {
70
+ stream.write(`\x1b[A\r\x1b[2K ${s.red('✖')} Could not reach ${getApiBase()} — check your connection.\n`);
71
+ process.exitCode = 1;
72
+ return;
73
+ }
74
+
75
+ if (res.status === 401) {
76
+ stream.write(`\x1b[A\r\x1b[2K ${s.red('✖')} Invalid token — check the value and try again.\n`);
77
+ process.exitCode = 1;
78
+ return;
79
+ }
80
+ if (!res.ok) {
81
+ stream.write(`\x1b[A\r\x1b[2K ${s.red('✖')} Server returned ${res.status}. Try again later.\n`);
82
+ process.exitCode = 1;
83
+ return;
84
+ }
85
+
86
+ saveCliTokenFn(token);
87
+ stream.write(`\x1b[A\r\x1b[2K ${s.green('✔')} Logged in.\n`);
88
+
89
+ // Flow 1: pull team Jira config for Pro/Team members (silently skipped for Free)
90
+ const tcLogin = await applyTeamConfigOnLoginFn().catch(() => null);
91
+ if (tcLogin?.ok) {
92
+ stream.write(` ${s.dim(`○ Team Jira config applied for ${s.cyan(tcLogin.groupName)}.`)}\n`);
93
+ }
94
+
95
+ if (showSyncHint) {
96
+ stream.write(`\n Run ${s.cyan('ticketlens sync')} to pull your connections.\n\n`);
97
+ }
98
+ }