ticketlens 0.10.1 → 0.10.3

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.10.1",
3
+ "version": "0.10.3",
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.1 -->
1
+ <!-- jtb-skill-version: 0.10.3 -->
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.
@@ -108,6 +108,16 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
108
108
  const licensedFn = opts.isLicensed ?? isLicensed;
109
109
  const upgradeFn = opts.showUpgradePrompt ?? showUpgradePrompt;
110
110
 
111
+ // Fail-fast: --push requires a CLI token — check before the triage scan fires
112
+ if (pushFlag) {
113
+ const pushToken = opts.cliToken ?? readCliToken(configDir) ?? null;
114
+ if (!pushToken) {
115
+ process.stderr.write('Error: --push requires authentication. Run `ticketlens auth login` to connect your account.\n');
116
+ process.exitCode = 1;
117
+ return;
118
+ }
119
+ }
120
+
111
121
  // --save=FILE: Pro gate + validate path is not a directory
112
122
  if (saveArg) {
113
123
  if (!licensedFn('pro', configDir)) {
@@ -987,6 +987,15 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
987
987
  const licensedFn = opts.isLicensed ?? isLicensed;
988
988
  const upgradeFn = opts.showUpgradePrompt ?? showUpgradePrompt;
989
989
 
990
+ // Fail-fast: check Pro gate before any network call for Pro-only flags
991
+ if (args.includes('--summarize') || args.includes('--handoff')) {
992
+ if (!licensedFn('pro', configDir)) {
993
+ upgradeFn('pro', args.includes('--handoff') ? '--handoff' : '--summarize');
994
+ process.exitCode = 1;
995
+ return;
996
+ }
997
+ }
998
+
990
999
  const noCache = args.includes('--no-cache');
991
1000
 
992
1001
  // Resolve brief cache TTL: configurable for Pro tier only, else fixed 4h default
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  export const DEFAULT_API_BASE = 'https://api.ticketlens.app';
7
+ export const DEFAULT_SITE_BASE = 'https://ticketlens.dev';
7
8
 
8
9
  // Matches localhost, 127.0.0.1, and any hostname ending in .test or .local,
9
10
  // with an optional port — all treated as local-only addresses.
@@ -13,6 +14,10 @@ export function apiBase() {
13
14
  return process.env?.TICKETLENS_API_URL ?? DEFAULT_API_BASE;
14
15
  }
15
16
 
17
+ export function siteBase() {
18
+ return process.env?.TICKETLENS_SITE_URL ?? DEFAULT_SITE_BASE;
19
+ }
20
+
16
21
  export function warnIfInsecure(url, warn) {
17
22
  if (url.startsWith('http://') && !LOCAL_HOST_RE.test(url)) {
18
23
  warn('⚠ Warning: TICKETLENS_API_URL uses an unencrypted HTTP connection. Use https:// for production.\n');
@@ -6,24 +6,43 @@
6
6
 
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
- import { buildAuthHeader } from './jira-client.mjs';
9
+ import { buildAuthHeader, isSafeRedirectUrl, validateResolvedHost, defaultLookupFor } from './jira-client.mjs';
10
10
  import { DEFAULT_CONFIG_DIR } from './config.mjs';
11
11
  const MAX_ATTACHMENTS = 20;
12
12
  const MAX_FILE_BYTES = 10 * 1024 * 1024; // 10 MB
13
+ const MAX_REDIRECT_HOPS = 2;
13
14
 
14
- // Blocks redirect targets to private/internal addresses (SSRF via redirect).
15
- // Mirrors BLOCKED_PATTERNS in jira-client.mjs; kept local to avoid coupling.
16
- const BLOCKED_REDIRECT_PATTERNS = [
17
- /^127\./, /^10\./, /^172\.(1[6-9]|2\d|3[01])\./, /^192\.168\./,
18
- /^169\.254\./, /^0\.0\.0\.0$/, /^::1$/, /^::ffff:/i, /^fc00:/i, /^fe80:/i, /^localhost$/i,
19
- ];
20
-
21
- function isSafeRedirectUrl(url) {
22
- let parsed;
23
- try { parsed = new URL(url); } catch { return false; }
24
- if (parsed.protocol !== 'https:') return false;
25
- const hostname = parsed.hostname.replace(/^\[|\]$/g, '');
26
- return !BLOCKED_REDIRECT_PATTERNS.some(re => re.test(hostname));
15
+ /**
16
+ * DNS-validates the initial URL, then follows any redirect chain manually,
17
+ * re-validating (blocklist + DNS) EVERY hop — not just the first.
18
+ * `redirect:'follow'` on the first hop's target would let that host issue a
19
+ * second, unvalidated redirect straight through (audit 2026-07-07 §4.1
20
+ * code-review remediation). Auth headers are only ever sent on the initial
21
+ * request — every hop after that is auth-less, matching Jira Cloud's
22
+ * presigned-URL CDN redirect model.
23
+ */
24
+ async function followValidatedRedirects(initialUrl, fetcher, lookup, headers) {
25
+ let url = initialUrl;
26
+ await validateResolvedHost(new URL(url).hostname, lookup);
27
+ let response = await fetcher(url, { headers, redirect: 'manual' });
28
+ let hops = 0;
29
+ while (response.status >= 300 && response.status < 400) {
30
+ hops++;
31
+ if (hops > MAX_REDIRECT_HOPS) {
32
+ throw new Error(`Too many redirects (>${MAX_REDIRECT_HOPS}) following ${initialUrl}`);
33
+ }
34
+ const location = response.headers?.get?.('location');
35
+ if (!location) throw new Error('Redirect without Location header');
36
+ const redirectUrl = new URL(location, url).href;
37
+ if (!isSafeRedirectUrl(redirectUrl)) {
38
+ throw new Error(`Redirect to blocked host (${new URL(redirectUrl).hostname}) rejected`);
39
+ }
40
+ await validateResolvedHost(new URL(redirectUrl).hostname, lookup);
41
+ url = redirectUrl;
42
+ // Safe CDN/S3 redirect — follow without auth (presigned URLs are self-authorizing)
43
+ response = await fetcher(url, { redirect: 'manual' });
44
+ }
45
+ return response;
27
46
  }
28
47
 
29
48
  /**
@@ -41,6 +60,7 @@ export async function downloadAttachments(ticket, opts = {}) {
41
60
  const {
42
61
  env = process.env,
43
62
  fetcher = globalThis.fetch,
63
+ lookup = defaultLookupFor(fetcher),
44
64
  configDir = DEFAULT_CONFIG_DIR,
45
65
  noCache = false,
46
66
  onProgress = null,
@@ -98,23 +118,7 @@ export async function downloadAttachments(ticket, opts = {}) {
98
118
  onProgress?.(` download ${a.filename}`);
99
119
  try {
100
120
  const headers = buildAuthHeader(env);
101
- // Probe with redirect:'manual' to intercept any redirect before following it.
102
- // Validates the redirect target against BLOCKED_REDIRECT_PATTERNS so a
103
- // Jira-origin URL that redirects to a private IP (e.g. 169.254.x.x) is rejected.
104
- const probe = await fetcher(a.content, { headers, redirect: 'manual' });
105
- let response;
106
- if (probe.status >= 300 && probe.status < 400) {
107
- const location = probe.headers?.get?.('location');
108
- if (!location) throw new Error('Redirect without Location header');
109
- const redirectUrl = new URL(location, a.content).href;
110
- if (!isSafeRedirectUrl(redirectUrl)) {
111
- throw new Error(`Redirect to blocked host (${new URL(redirectUrl).hostname}) rejected`);
112
- }
113
- // Safe CDN/S3 redirect — follow without auth (presigned URLs are self-authorizing)
114
- response = await fetcher(redirectUrl, { redirect: 'follow' });
115
- } else {
116
- response = probe;
117
- }
121
+ const response = await followValidatedRedirects(a.content, fetcher, lookup, headers);
118
122
  if (!response.ok) throw new Error(`HTTP ${response.status} (${response.statusText})`);
119
123
  const buffer = await response.arrayBuffer();
120
124
  fs.writeFileSync(localPath, Buffer.from(buffer));
@@ -19,6 +19,7 @@ import { DEFAULT_BRIEF_TTL } from './brief-cache.mjs';
19
19
  import { DEFAULT_CONFIG_DIR } 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
+ import { siteBase } from './api-utils.mjs';
22
23
 
23
24
  const RETRY_OPTIONS = [
24
25
  { label: 'Retry', sublabel: 'Try again — same credentials (e.g. VPN just connected)', value: 'retry' },
@@ -384,7 +385,7 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
384
385
  );
385
386
  cacheTtl = ttlInput || curTtl;
386
387
  } else {
387
- stream.write(` ${s.dim('○')} Brief cache TTL: ${s.dim('4h')} ${s.dim('·')} ${s.cyan('Pro')} ${s.dim('unlocks configurable TTL → ticketlens.dev/pricing')}\n`);
388
+ stream.write(` ${s.dim('○')} Brief cache TTL: ${s.dim('4h')} ${s.dim('·')} ${s.cyan('Pro')} ${s.dim(`unlocks configurable TTL → ${siteBase()}/#pricing`)}\n`);
388
389
  }
389
390
 
390
391
  // ── Save ───────────────────────────────────────────────────────────────────
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { adfToText } from './adf-converter.mjs';
8
+ import { lookup as dnsLookup } from 'node:dns/promises';
8
9
 
9
10
  function toText(value) {
10
11
  if (value == null) return null;
@@ -100,21 +101,30 @@ export function normalizeTicket(raw) {
100
101
  };
101
102
  }
102
103
 
103
- // RFC-1918 + link-local + loopback + localhost blocked to prevent SSRF.
104
+ // RFC-1918 + link-local + loopback + localhost + IPv6 ULA/link-local + CGN NAT
105
+ // blocked to prevent SSRF. Each pattern matches the FULL CIDR range, not a
106
+ // single literal value — a narrower match (e.g. exact "fc00:") lets addresses
107
+ // elsewhere in the same reserved block sail through (audit 2026-07-07 §4.1
108
+ // code-review remediation).
104
109
  const BLOCKED_PATTERNS = [
105
- /^127\./,
106
- /^10\./,
107
- /^172\.(1[6-9]|2\d|3[01])\./,
108
- /^192\.168\./,
109
- /^169\.254\./,
110
- /^0\.0\.0\.0$/,
111
- /^::1$/,
112
- /^::ffff:/i,
113
- /^fc00:/i,
114
- /^fe80:/i,
110
+ /^127\./, // 127.0.0.0/8 loopback
111
+ /^10\./, // 10.0.0.0/8 RFC1918
112
+ /^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12 RFC1918
113
+ /^192\.168\./, // 192.168.0.0/16 RFC1918
114
+ /^169\.254\./, // 169.254.0.0/16 link-local + cloud metadata
115
+ /^0\./, // 0.0.0.0/8 "this network"
116
+ /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, // 100.64.0.0/10 Carrier-Grade NAT
117
+ /^::1$/, // IPv6 loopback
118
+ /^::ffff:/i, // IPv4-mapped IPv6
119
+ /^f[cd][0-9a-f]{2}:/i, // fc00::/7 Unique Local Address (covers real-world fd00::/8)
120
+ /^fe[89ab][0-9a-f]:/i, // fe80::/10 link-local
115
121
  /^localhost$/i,
116
122
  ];
117
123
 
124
+ function normalizeHostname(hostname) {
125
+ return hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '');
126
+ }
127
+
118
128
  export function validateBaseUrl(url) {
119
129
  let parsed;
120
130
  try { parsed = new URL(url); } catch {
@@ -123,12 +133,112 @@ export function validateBaseUrl(url) {
123
133
  if (parsed.protocol !== 'https:') {
124
134
  throw new Error(`JIRA_BASE_URL must use HTTPS (got ${parsed.protocol})`);
125
135
  }
126
- const hostname = parsed.hostname.replace(/^\[|\]$/g, '');
136
+ const hostname = normalizeHostname(parsed.hostname);
127
137
  if (BLOCKED_PATTERNS.some(re => re.test(hostname))) {
128
138
  throw new Error(`JIRA_BASE_URL hostname is blocked (${hostname})`);
129
139
  }
130
140
  }
131
141
 
142
+ /**
143
+ * Validates a redirect target URL: HTTPS + hostname not in the private/internal
144
+ * blocklist. Shared by attachment-downloader (CDN redirect follow) — the API
145
+ * client itself never follows redirects (see guardedFetch).
146
+ */
147
+ export function isSafeRedirectUrl(url) {
148
+ let parsed;
149
+ try { parsed = new URL(url); } catch { return false; }
150
+ if (parsed.protocol !== 'https:') return false;
151
+ const hostname = normalizeHostname(parsed.hostname);
152
+ return !BLOCKED_PATTERNS.some(re => re.test(hostname));
153
+ }
154
+
155
+ /**
156
+ * Shared default for the `lookup` opt across every call site: the real DNS
157
+ * resolver only when the caller is using the real fetch (production), null
158
+ * for mocked fetchers (tests — no socket opens, nothing to protect).
159
+ */
160
+ export function defaultLookupFor(fetcher) {
161
+ return fetcher === globalThis.fetch ? dnsLookup : null;
162
+ }
163
+
164
+ // Per-lookup-fn DNS validation cache: WeakMap<lookupFn, Map<hostname, Promise<void>>>.
165
+ // Keying by the lookup function isolates test stubs from each other and from the
166
+ // real resolver, with zero reset hooks needed. Entries are deleted once their
167
+ // promise settles (see below) — this dedupes only genuinely concurrent lookups,
168
+ // never sequential ones.
169
+ const dnsValidationCaches = new WeakMap();
170
+
171
+ /**
172
+ * DNS-rebinding guard: resolves `hostname` and rejects if ANY returned address
173
+ * matches BLOCKED_PATTERNS. The upfront validateBaseUrl check only sees the
174
+ * hostname *string*; a passing hostname can still resolve to 127.0.0.1 or
175
+ * 169.254.169.254 at connect time. Fail-closed: resolver errors propagate.
176
+ *
177
+ * Cache entries are deleted as soon as their promise settles, so the cache
178
+ * only dedupes calls that are truly in flight at the same instant (e.g. a
179
+ * Promise.all fan-out over linked tickets) — it never lets a sequential call
180
+ * reuse a stale verdict. A cache with no expiry would hand a DNS-rebinding
181
+ * attacker the whole CLI run's wall-clock duration to flip the record after
182
+ * the first (validated) resolution; this way each sequential call re-resolves.
183
+ *
184
+ * Known residual: TOCTOU between this lookup and fetch's own resolution within
185
+ * a SINGLE call — a TTL-0 rebinder can still race that one connection. Full
186
+ * IP-pinning needs a custom undici Agent, which would break the zero-dependency
187
+ * constraint. This check raises the bar; it does not eliminate a single-call race.
188
+ *
189
+ * `lookup` is null when the fetcher is mocked (tests) — no socket opens, so
190
+ * there is nothing to protect. Production always uses globalThis.fetch, which
191
+ * always gets the real resolver (see defaultLookupFor).
192
+ */
193
+ export async function validateResolvedHost(hostname, lookup) {
194
+ if (!lookup) return;
195
+ let cache = dnsValidationCaches.get(lookup);
196
+ if (!cache) {
197
+ cache = new Map();
198
+ dnsValidationCaches.set(lookup, cache);
199
+ }
200
+ if (!cache.has(hostname)) {
201
+ const validation = (async () => {
202
+ const addresses = await lookup(hostname, { all: true });
203
+ for (const { address } of addresses) {
204
+ if (BLOCKED_PATTERNS.some(re => re.test(address))) {
205
+ throw new Error(`Hostname ${hostname} resolves to a blocked address (${address}) — refusing to connect`);
206
+ }
207
+ }
208
+ })();
209
+ validation.catch(() => {}).finally(() => cache.delete(hostname));
210
+ cache.set(hostname, validation);
211
+ }
212
+ return cache.get(hostname);
213
+ }
214
+
215
+ /**
216
+ * Single enforcement point for all Jira API calls:
217
+ * 1. DNS-validate the target host (when a resolver is available).
218
+ * 2. Force redirect:'manual' — call sites cannot forget or override it.
219
+ * 3. Refuse ANY 3xx. A redirect on a REST endpoint means JIRA_BASE_URL is
220
+ * misconfigured, not normal Jira behavior. Refusing (instead of following)
221
+ * means credentials can never cross a redirect — this deliberately keeps
222
+ * fetch's own auth-strip protection irrelevant rather than overriding it.
223
+ * Error message carries the redirect target's HOSTNAME only — Location values
224
+ * can contain presigned tokens in path/query.
225
+ */
226
+ export async function guardedFetch(url, fetchOpts, { fetcher, lookup }) {
227
+ await validateResolvedHost(new URL(url).hostname, lookup);
228
+ const response = await fetcher(url, { ...fetchOpts, redirect: 'manual' });
229
+ if (response.status >= 300 && response.status < 400) {
230
+ let target = 'unknown host';
231
+ const location = response.headers?.get?.('location');
232
+ if (location) {
233
+ try { target = new URL(location, url).hostname; } catch {}
234
+ }
235
+ const err = new Error(`Jira API redirected to ${target} — refusing to follow; check JIRA_BASE_URL`);
236
+ err.status = response.status;
237
+ throw err;
238
+ }
239
+ return response;
240
+ }
241
+
132
242
  export function buildAuthHeader(env) {
133
243
  if (env.JIRA_PAT) {
134
244
  return { Authorization: `Bearer ${env.JIRA_PAT}` };
@@ -138,7 +248,7 @@ export function buildAuthHeader(env) {
138
248
  }
139
249
 
140
250
  export async function fetchCurrentUser(opts = {}) {
141
- const { env = process.env, fetcher = globalThis.fetch, apiVersion = 2, timeoutMs = 10_000 } = opts;
251
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000 } = opts;
142
252
  validateBaseUrl(env.JIRA_BASE_URL);
143
253
  const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
144
254
  const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
@@ -146,7 +256,7 @@ export async function fetchCurrentUser(opts = {}) {
146
256
  const url = `${baseUrl}/rest/api/${apiVersion}/myself`;
147
257
  const fetchOpts = { headers };
148
258
  if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
149
- const response = await fetcher(url, fetchOpts);
259
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
150
260
 
151
261
  if (!response.ok) {
152
262
  const err = new Error(`Jira API error ${response.status} fetching current user`);
@@ -164,7 +274,7 @@ export async function fetchCurrentUser(opts = {}) {
164
274
  }
165
275
 
166
276
  export async function fetchStatuses(opts = {}) {
167
- const { env = process.env, fetcher = globalThis.fetch, apiVersion = 2, timeoutMs = 10_000 } = opts;
277
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000 } = opts;
168
278
  validateBaseUrl(env.JIRA_BASE_URL);
169
279
  const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
170
280
  const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
@@ -172,7 +282,7 @@ export async function fetchStatuses(opts = {}) {
172
282
  const url = `${baseUrl}/rest/api/${apiVersion}/status`;
173
283
  const fetchOpts = { headers };
174
284
  if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
175
- const response = await fetcher(url, fetchOpts);
285
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
176
286
 
177
287
  if (!response.ok) {
178
288
  const err = new Error(`Jira API error ${response.status} fetching statuses`);
@@ -185,7 +295,7 @@ export async function fetchStatuses(opts = {}) {
185
295
  }
186
296
 
187
297
  export async function searchTickets(jql, opts = {}) {
188
- const { env = process.env, fetcher = globalThis.fetch, maxResults = 50, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false } = opts;
298
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), maxResults = 50, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false } = opts;
189
299
  validateBaseUrl(env.JIRA_BASE_URL);
190
300
  const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
191
301
  const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
@@ -197,7 +307,7 @@ export async function searchTickets(jql, opts = {}) {
197
307
  const url = `${baseUrl}${endpoint}?${params}`;
198
308
  const fetchOpts = { headers };
199
309
  if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
200
- const response = await fetcher(url, fetchOpts);
310
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
201
311
 
202
312
  if (!response.ok) {
203
313
  let detail = '';
@@ -213,7 +323,7 @@ export async function searchTickets(jql, opts = {}) {
213
323
  }
214
324
 
215
325
  export async function fetchRemoteLinks(ticketKey, opts = {}) {
216
- const { env = process.env, fetcher = globalThis.fetch, apiVersion = 2, timeoutMs = 10_000 } = opts;
326
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000 } = opts;
217
327
  validateBaseUrl(env.JIRA_BASE_URL);
218
328
  const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
219
329
  const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}/remotelink`;
@@ -221,7 +331,7 @@ export async function fetchRemoteLinks(ticketKey, opts = {}) {
221
331
  const fetchOpts = { headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' } };
222
332
  if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
223
333
 
224
- const response = await fetcher(url, fetchOpts);
334
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
225
335
  if (!response.ok) return [];
226
336
 
227
337
  const raw = await response.json();
@@ -231,7 +341,7 @@ export async function fetchRemoteLinks(ticketKey, opts = {}) {
231
341
  }
232
342
 
233
343
  export async function fetchTicket(ticketKey, opts = {}) {
234
- const { env = process.env, fetcher = globalThis.fetch, depth = 1, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false, _visited = new Set(), _currentDepth = 0 } = 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;
235
345
  validateBaseUrl(env.JIRA_BASE_URL);
236
346
  const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
237
347
  const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
@@ -240,7 +350,7 @@ export async function fetchTicket(ticketKey, opts = {}) {
240
350
  const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}${expand}`;
241
351
  const fetchOpts = { headers };
242
352
  if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
243
- const response = await fetcher(url, fetchOpts);
353
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
244
354
 
245
355
  if (!response.ok) {
246
356
  const err = new Error(`Jira API error ${response.status} fetching ${ticketKey}`);
@@ -4,6 +4,7 @@ import os from 'node:os';
4
4
  import crypto from 'node:crypto';
5
5
  import { DEFAULT_CONFIG_DIR } from './config.mjs';
6
6
  import { createStyler } from './ansi.mjs';
7
+ import { siteBase } from './api-utils.mjs';
7
8
 
8
9
  // Mixed into the HMAC key so that knowing the license key alone is not sufficient
9
10
  // to forge a valid signature — an attacker also needs this constant from the source.
@@ -14,7 +15,7 @@ const LICENSE_SECRET_FILE = 'license-hmac-secret.json';
14
15
  const REVALIDATION_DAYS = 7; // attempt background revalidation after this many days
15
16
  const GRACE_DAYS = 30; // treat license as invalid if not revalidated within this window
16
17
  const MS_PER_DAY = 86400000;
17
- const UPGRADE_URL = 'https://ticketlens.dev/pricing';
18
+ const upgradeUrl = () => `${siteBase()}/#pricing`;
18
19
 
19
20
  const ANSI_RE_LIC = /\x1b\[[0-9;]*m|\x1b\]8;[^\x07]*\x07/g;
20
21
  const visLen = (s) => s.replace(ANSI_RE_LIC, '').length;
@@ -138,7 +139,8 @@ export function showUpgradePrompt(requiredTier, featureFlag, { stream = process.
138
139
  const bc = (t) => s.dim(t);
139
140
 
140
141
  const featureLine = padInner(` ${s.yellow('◆')} ${s.bold(featureFlag)} requires ${s.bold(s.cyan(tier))}`, W);
141
- const upgradeLine = padInner(` ${s.dim('Upgrade:')} ${s.link(UPGRADE_URL, s.cyan(UPGRADE_URL))}`, W);
142
+ const url = upgradeUrl();
143
+ const upgradeLine = padInner(` ${s.dim('Upgrade:')} ${s.link(url, s.cyan(url))}`, W);
142
144
  const activateLine = padInner(` ${s.dim('Or run:')} ${s.dim('ticketlens activate <KEY>')}`, W);
143
145
  const blank = ' '.repeat(W);
144
146