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
package/README.md CHANGED
@@ -55,6 +55,8 @@ npx ticketlens init
55
55
  npx ticketlens CNV1-2
56
56
  ```
57
57
 
58
+ Tip: `tl` works everywhere `ticketlens` does — running `tl`/`ticketlens config` before anything is configured also launches guided setup, no dead end. Pass `--no-input` to force non-interactive behavior even in a terminal (scripts, CI).
59
+
58
60
  **Prerequisites:** Node.js >=20
59
61
 
60
62
  ---
@@ -77,7 +79,7 @@ npx ticketlens CNV1-2
77
79
  | `ticketlens profiles` | List all configured profiles (alias: `ticketlens ls`) |
78
80
  | `ticketlens delete <NAME>` | Remove a profile and its credentials (prompts `y/N` in TTY; use `--yes` in scripts/CI) |
79
81
 
80
- `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.
81
83
 
82
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.
83
85
 
@@ -7,8 +7,8 @@
7
7
  * ticketlens triage [--stale=N] [--status=X,Y] [--profile=NAME]
8
8
  */
9
9
 
10
- import { createRequire } from 'node:module';
11
10
  import { parseCommand } from '../skills/jtb/scripts/lib/cli.mjs';
11
+ import { renderWordmark } from '../skills/jtb/scripts/lib/wordmark.mjs';
12
12
  import { run as runFetch } from '../skills/jtb/scripts/fetch-ticket.mjs';
13
13
  import { run as runTriage } from '../skills/jtb/scripts/fetch-my-tickets.mjs';
14
14
  import { run as runInit } from '../skills/jtb/scripts/lib/init-wizard.mjs';
@@ -29,14 +29,13 @@ import {
29
29
  } from '../skills/jtb/scripts/lib/help.mjs';
30
30
  import { runStats } from '../skills/jtb/scripts/lib/run-stats.mjs';
31
31
  import { createStyler } from '../skills/jtb/scripts/lib/ansi.mjs';
32
- import { readCliToken, saveCliToken, deleteCliToken } from '../skills/jtb/scripts/lib/cli-auth.mjs';
33
- import { browserLogin } from '../skills/jtb/scripts/lib/browser-login.mjs';
34
- import { syncProfiles, getApiBase, getConsoleBase } from '../skills/jtb/scripts/lib/sync.mjs';
35
- import { promptSecret, promptText } from '../skills/jtb/scripts/lib/prompt-helpers.mjs';
32
+ import { readCliToken, deleteCliToken } from '../skills/jtb/scripts/lib/cli-auth.mjs';
33
+ import { runLogin } from '../skills/jtb/scripts/lib/login-flow.mjs';
34
+ import { syncProfiles, reportSyncResult, getApiBase } from '../skills/jtb/scripts/lib/sync.mjs';
36
35
  import { checkForUpdate, getUpdateHint } from '../skills/jtb/scripts/lib/update-check.mjs';
37
36
  import { incrementInvocation, incrementCommand } from '../skills/jtb/scripts/lib/activity-counter.mjs';
38
37
  import { DEFAULT_CONFIG_DIR } from '../skills/jtb/scripts/lib/config.mjs';
39
- import { applyTeamConfigOnLogin, checkTeamJiraConfigUpdate } from '../skills/jtb/scripts/lib/team-jira-sync.mjs';
38
+ import { checkTeamJiraConfigUpdate } from '../skills/jtb/scripts/lib/team-jira-sync.mjs';
40
39
 
41
40
  const TRACKED_COMMANDS = new Set([
42
41
  'triage', 'fetch', 'get', 'compliance', 'review', 'standup',
@@ -184,7 +183,24 @@ switch (command) {
184
183
 
185
184
  const profileArg = cmdArgs.find(a => a.startsWith('--profile='));
186
185
  const profileName = profileArg ? profileArg.split('=')[1] : undefined;
187
- runConfig({ profileName }).catch(err => {
186
+ const isInteractive = process.stdin.isTTY && process.stdout.isTTY && !process.env.CI && !cmdArgs.includes('--no-input');
187
+
188
+ (async () => {
189
+ if (isInteractive) {
190
+ const { detectSetupState } = await import('../skills/jtb/scripts/lib/setup-state.mjs');
191
+ // Ready + no explicit --profile= honors the hub's own "Exit" sublabel
192
+ // promise ("you can rerun this any time: ticketlens config"). An
193
+ // explicit --profile= is a targeted, scriptable invocation — bypass
194
+ // the hub and edit that profile directly, unchanged from today.
195
+ if (detectSetupState().status !== 'ready' || !profileName) {
196
+ const { run: runOnboarding } = await import('../skills/jtb/scripts/lib/onboarding.mjs');
197
+ await runOnboarding({ stream: process.stderr });
198
+ return;
199
+ }
200
+ }
201
+
202
+ await runConfig({ profileName });
203
+ })().catch(err => {
188
204
  process.stderr.write(`Error: ${err.message}\n`);
189
205
  process.exitCode = 1;
190
206
  });
@@ -329,12 +345,9 @@ switch (command) {
329
345
  });
330
346
  break;
331
347
 
332
- case 'version': {
333
- const require = createRequire(import.meta.url);
334
- const pkg = require('../package.json');
335
- process.stdout.write(`ticketlens v${pkg.version}\n`);
348
+ case 'version':
349
+ process.stdout.write(renderWordmark({ stream: process.stdout }));
336
350
  break;
337
- }
338
351
 
339
352
  case 'schedule': {
340
353
  if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printScheduleHelp(); break; }
@@ -430,78 +443,7 @@ switch (command) {
430
443
  const useManual = cmdArgs.includes('--manual');
431
444
 
432
445
  (async () => {
433
- const s = createStyler({ isTTY: process.stderr.isTTY });
434
-
435
- let token;
436
-
437
- if (useManual) {
438
- // ── manual paste flow (CI / headless environments) ──────────────────
439
- process.stderr.write(`\n ${s.bold('TicketLens Login')}\n`);
440
- process.stderr.write(` ${s.dim('─'.repeat(44))}\n`);
441
- process.stderr.write(` ${s.dim(`Generate a CLI token at ${s.cyan(`${getConsoleBase()}/console/account`)}`)}\n`);
442
- process.stderr.write(` ${s.dim('then paste it below.')}\n\n`);
443
-
444
- token = await promptSecret(`CLI Token ${s.dim('(tl_…)')}:`, { stream: process.stderr });
445
- if (!token.startsWith('tl_')) {
446
- process.stderr.write(` ${s.red('✖')} Token must start with ${s.dim('tl_')}\n`);
447
- process.exitCode = 1;
448
- return;
449
- }
450
- } else {
451
- // ── browser flow (default) ────────────────────────────────────────
452
- process.stderr.write(`\n ${s.bold('TicketLens Login')}\n`);
453
- process.stderr.write(` ${s.dim('─'.repeat(44))}\n`);
454
- process.stderr.write(` Opening browser to authorize…\n\n`);
455
- process.stderr.write(` ${s.dim('○ Waiting for authorization (120s)…')}\n`);
456
-
457
- try {
458
- token = await browserLogin();
459
- } catch (err) {
460
- const cancelled = err.message === 'Authorization cancelled';
461
- process.stderr.write(`\x1b[A\r\x1b[2K ${s.red('✖')} ${cancelled ? 'Login cancelled.' : err.message}\n`);
462
- if (!cancelled) {
463
- process.stderr.write(`\n ${s.dim(`Try ${s.cyan('ticketlens login --manual')} to paste a token instead.`)}\n\n`);
464
- }
465
- process.exitCode = cancelled ? 0 : 1;
466
- return;
467
- }
468
- }
469
-
470
- // ── verify token against API (both flows) ─────────────────────────
471
- process.stderr.write(`\n ${s.dim('○ Verifying token…')}\n`);
472
- let res;
473
- try {
474
- res = await fetch(`${getApiBase()}/v1/profiles`, {
475
- headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
476
- signal: AbortSignal.timeout(15000),
477
- });
478
- } catch {
479
- process.stderr.write(`\x1b[A\r\x1b[2K ${s.red('✖')} Could not reach ${getApiBase()} — check your connection.\n`);
480
- process.exitCode = 1;
481
- return;
482
- }
483
-
484
- if (res.status === 401) {
485
- process.stderr.write(`\x1b[A\r\x1b[2K ${s.red('✖')} Invalid token — check the value and try again.\n`);
486
- process.exitCode = 1;
487
- return;
488
- }
489
- if (!res.ok) {
490
- process.stderr.write(`\x1b[A\r\x1b[2K ${s.red('✖')} Server returned ${res.status}. Try again later.\n`);
491
- process.exitCode = 1;
492
- return;
493
- }
494
-
495
- saveCliToken(token);
496
- process.stderr.write(`\x1b[A\r\x1b[2K ${s.green('✔')} Logged in.\n`);
497
-
498
- // Flow 1: pull team Jira config for Pro/Team members (silently skipped for Free)
499
- const tcLogin = await applyTeamConfigOnLogin().catch(() => null);
500
- if (tcLogin?.ok) {
501
- process.stderr.write(` ${s.dim(`○ Team Jira config applied for ${s.cyan(tcLogin.groupName)}.`)}\n`);
502
- }
503
-
504
- process.stderr.write(`\n Run ${s.cyan('ticketlens sync')} to pull your connections.\n\n`);
446
+ await runLogin({ manual: useManual });
505
447
  })().catch(err => {
506
448
  process.stderr.write(`Error: ${err.message}\n`);
507
449
  process.exitCode = 1;
@@ -525,42 +467,14 @@ switch (command) {
525
467
 
526
468
  const result = await syncProfiles();
527
469
 
528
- if (result.error === 'no-token') {
529
- process.stderr.write(`\x1b[A\r\x1b[2K ${s.red('✖')} Not logged in. Run ${s.cyan('ticketlens login')} first.\n\n`);
530
- process.exitCode = 1;
531
- return;
532
- }
533
- if (result.error === 'unauthorized') {
534
- process.stderr.write(`\x1b[A\r\x1b[2K ${s.red('✖')} Token expired or revoked. Run ${s.cyan('ticketlens login')} to re-authenticate.\n\n`);
535
- process.exitCode = 1;
536
- return;
537
- }
470
+ process.stderr.write('\x1b[A\r\x1b[2K');
471
+ reportSyncResult(result, { stream: process.stderr });
538
472
  if (result.error) {
539
- process.stderr.write(`\x1b[A\r\x1b[2K ${s.red('✖')} Sync failed: ${result.error}\n\n`);
473
+ process.stderr.write('\n');
540
474
  process.exitCode = 1;
541
475
  return;
542
476
  }
543
477
 
544
- const { added, updated, unchanged, needsCredentials } = result;
545
- const total = added.length + updated.length + unchanged.length;
546
-
547
- process.stderr.write(`\x1b[A\r\x1b[2K ${s.green('✔')} Sync complete`);
548
- if (total === 0) {
549
- process.stderr.write(` — no profiles on console yet.\n`);
550
- } else {
551
- process.stderr.write(`\n`);
552
- if (added.length) process.stderr.write(` ${s.dim('+')} ${added.length} added: ${added.map(n => s.cyan(n)).join(', ')}\n`);
553
- if (updated.length) process.stderr.write(` ${s.dim('↑')} ${updated.length} updated: ${updated.map(n => s.cyan(n)).join(', ')}\n`);
554
- if (unchanged.length) process.stderr.write(` ${s.dim('○')} ${unchanged.length} unchanged\n`);
555
- }
556
-
557
- if (needsCredentials.length > 0) {
558
- process.stderr.write(`\n ${s.yellow('!')} These profiles need credentials before they can be used:\n`);
559
- for (const name of needsCredentials) {
560
- process.stderr.write(` ${s.dim('○')} ${s.cyan(name)} — run: ${s.bold(`ticketlens config --profile=${name}`)}\n`);
561
- }
562
- }
563
-
564
478
  // Flow 3: also pull team Jira config update (Pro/Team); silently skipped for Free
565
479
  const tcSync = await checkTeamJiraConfigUpdate().catch(() => null);
566
480
  if (tcSync?.banner) {
@@ -694,7 +608,23 @@ switch (command) {
694
608
  }
695
609
 
696
610
  case 'help':
697
- default:
698
- printHelp();
611
+ default: {
612
+ const isInteractive = args.length === 0 && process.stdin.isTTY && process.stdout.isTTY && !process.env.CI;
613
+
614
+ (async () => {
615
+ if (isInteractive) {
616
+ const { detectSetupState } = await import('../skills/jtb/scripts/lib/setup-state.mjs');
617
+ if (detectSetupState().status !== 'ready') {
618
+ const { run: runOnboarding } = await import('../skills/jtb/scripts/lib/onboarding.mjs');
619
+ await runOnboarding({ stream: process.stderr });
620
+ return;
621
+ }
622
+ }
623
+ printHelp();
624
+ })().catch(err => {
625
+ process.stderr.write(`Error: ${err.message}\n`);
626
+ process.exitCode = 1;
627
+ });
699
628
  break;
629
+ }
700
630
  }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.10.4",
3
+ "version": "0.11.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": {
7
- "ticketlens": "bin/ticketlens.mjs"
7
+ "ticketlens": "bin/ticketlens.mjs",
8
+ "tl": "bin/ticketlens.mjs"
8
9
  },
9
10
  "files": [
10
11
  "bin/",
@@ -12,6 +12,7 @@ import { homedir } from 'node:os';
12
12
 
13
13
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
14
  const SKILL_SRC = join(__dirname, '..', 'skills', 'jtb', 'SKILL.md');
15
+ const OWN_BIN = join(__dirname, '..', 'bin', 'ticketlens.mjs');
15
16
 
16
17
  if (!existsSync(SKILL_SRC)) process.exit(0);
17
18
 
@@ -60,3 +61,36 @@ if (updated === 0 && skipped === TARGETS.length) {
60
61
  console.log(' ℹ /jtb skill not installed in any known location.');
61
62
  console.log(' To install: ticketlens update-skill');
62
63
  }
64
+
65
+ // First-run banner + tl-alias status. Best-effort: npm >=7 hides this output
66
+ // unless the user passes --foreground-scripts, so the guaranteed channel is
67
+ // the first bare `tl`/`ticketlens` run added in a later phase. Never throws —
68
+ // a broken banner must not break the install.
69
+ try {
70
+ const { renderWordmark } = await import('../skills/jtb/scripts/lib/wordmark.mjs');
71
+ const { checkAliasStatus } = await import('../skills/jtb/scripts/lib/alias-status.mjs');
72
+ const { loadProfiles } = await import('../skills/jtb/scripts/lib/profile-resolver.mjs');
73
+
74
+ console.log('\n' + renderWordmark({ stream: process.stdout }));
75
+
76
+ const profiles = loadProfiles();
77
+ const isConfigured = !!(profiles && Object.keys(profiles.profiles || {}).length > 0);
78
+
79
+ const alias = checkAliasStatus({ selfBinPath: OWN_BIN });
80
+ if (alias.status === 'active') {
81
+ console.log(' ✔ Also available as: tl');
82
+ } else if (alias.status === 'shadowed') {
83
+ console.log(` ⚠ 'tl' on this machine points to ${alias.foreignPath} — use 'ticketlens' instead.`);
84
+ }
85
+ // alias.status === 'missing' → npm bin dir not on PATH is a broader problem
86
+ // this script can't fix; saying nothing avoids confusing the user further.
87
+
88
+ if (isConfigured) {
89
+ console.log(' → Try tl triage');
90
+ } else {
91
+ console.log(' → New here? Run tl to launch the guided setup.');
92
+ }
93
+ console.log('');
94
+ } catch {
95
+ // Silent on failure — never breaks the install.
96
+ }
@@ -1,4 +1,4 @@
1
- <!-- jtb-skill-version: 0.10.4 -->
1
+ <!-- jtb-skill-version: 0.11.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.
@@ -59,7 +59,7 @@ TicketLens supports two connection methods — check in this order:
59
59
 
60
60
  **1. Profile config (recommended):** If `~/.ticketlens/profiles.json` exists, no env vars
61
61
  are needed. Profile resolution is automatic (by ticket prefix, project path, or `--profile`).
62
- Setup via `ticketlens init`.
62
+ Setup via `ticketlens init` (`tl init` also works — `tl` is shorthand for `ticketlens`).
63
63
 
64
64
  **2. Env var fallback:** If no profile config exists, these must be set:
65
65
  - `JIRA_BASE_URL` — e.g. `https://yourteam.atlassian.net`
@@ -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
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Detects whether the `tl` shorthand resolves to this package's own
3
+ * bin/ticketlens.mjs, a foreign binary shadowing it on PATH, or nothing.
4
+ * Pure filesystem checks only — no subprocess spawning, no network.
5
+ */
6
+
7
+ import { existsSync, accessSync, realpathSync, constants as fsConstants } from 'node:fs';
8
+ import { join, dirname } from 'node:path';
9
+
10
+ const DEFAULT_PATHEXT = '.COM;.EXE;.BAT;.CMD';
11
+
12
+ /**
13
+ * Scan PATH for the first executable matching binName. Never throws —
14
+ * an unreadable or missing directory is skipped, not fatal.
15
+ */
16
+ export function findOnPath(binName, { env = process.env, platform = process.platform } = {}) {
17
+ const isWin = platform === 'win32';
18
+ // PATH delimiter depends on the target platform, not the host running this
19
+ // code — node:path's `delimiter` is host-bound and wrong when platform is
20
+ // passed explicitly (e.g. testing win32 logic from a POSIX host).
21
+ const dirs = (env.PATH || env.Path || '').split(isWin ? ';' : ':').filter(Boolean);
22
+ const candidates = isWin
23
+ ? [...(env.PATHEXT || DEFAULT_PATHEXT).split(';').map((ext) => binName + ext), binName]
24
+ : [binName];
25
+
26
+ for (const dir of dirs) {
27
+ for (const name of candidates) {
28
+ try {
29
+ const full = join(dir, name);
30
+ if (!existsSync(full)) continue;
31
+ // POSIX: skip non-executable matches (a shell would too). Windows PATHEXT
32
+ // already restricts candidates to executable extensions, so no check needed.
33
+ if (!isWin) accessSync(full, fsConstants.X_OK);
34
+ return full;
35
+ } catch {
36
+ continue;
37
+ }
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * Compare where `tl` resolves against our own installed ticketlens binary.
45
+ * POSIX: realpath comparison (npm bins are symlinks) against selfBinPath.
46
+ * Windows: npm .cmd/.ps1 shims aren't real symlinks, and selfBinPath (the
47
+ * package's internal target script under node_modules) lives in a different
48
+ * directory than the shims themselves — comparing against it always reads
49
+ * as foreign. Instead, npm always installs every bin entry from one package
50
+ * into the same shim directory together, so confirm ownership by checking
51
+ * whether a `ticketlens` shim sits alongside the found `tl` shim.
52
+ */
53
+ export function checkAliasStatus({ selfBinPath, env = process.env, platform = process.platform } = {}) {
54
+ try {
55
+ const tlPath = findOnPath('tl', { env, platform });
56
+ if (!tlPath) return { status: 'missing' };
57
+
58
+ if (platform === 'win32') {
59
+ const ticketlensPath = findOnPath('ticketlens', { env, platform });
60
+ return ticketlensPath && dirname(tlPath) === dirname(ticketlensPath)
61
+ ? { status: 'active' }
62
+ : { status: 'shadowed', foreignPath: tlPath };
63
+ }
64
+
65
+ const resolvedTl = realpathSync(tlPath);
66
+ const resolvedSelf = realpathSync(selfBinPath);
67
+ return resolvedTl === resolvedSelf
68
+ ? { status: 'active' }
69
+ : { status: 'shadowed', foreignPath: tlPath };
70
+ } catch {
71
+ return { status: 'missing' };
72
+ }
73
+ }
@@ -4,7 +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
+ export const DEFAULT_SITE_BASE = 'https://ticketlens.app';
8
8
 
9
9
  // Matches localhost, 127.0.0.1, and any hostname ending in .test or .local,
10
10
  // with an optional port — all treated as local-only addresses.
@@ -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));
@@ -8,7 +8,7 @@ export const TICKET_KEY_PATTERN = /^[A-Z][A-Z0-9]+-\d+$/;
8
8
  export function parseCommand(args) {
9
9
  const first = args[0];
10
10
 
11
- if (args.length === 0 || first === '--help' || first === '-h') {
11
+ if (args.length === 0 || first === '--help' || first === '-h' || first === '--no-input') {
12
12
  return { command: 'help', args: [] };
13
13
  }
14
14
 
@@ -16,7 +16,7 @@ import { loadProfiles, loadCredentials, saveProfile } from './profile-resolver.m
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';
@@ -103,6 +103,10 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
103
103
  let email = profile.email || '';
104
104
  const existingToken = profileCreds.pat || profileCreds.apiToken || '';
105
105
  let token = existingToken;
106
+ // Trust is bound to the exact hostname it was granted for — never a flat
107
+ // per-profile boolean. Editing the URL to a different host must re-trigger
108
+ // the private-IP block and a fresh "Trust this connection?" prompt.
109
+ let trustedHostname = (profile.allowPrivateIp && hostnameOf(profile.baseUrl)) || null;
106
110
 
107
111
  // ── URL ───────────────────────────────────────────────────────────────────
108
112
  const urlTyped = await promptText(
@@ -158,6 +162,24 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
158
162
  let connected = false;
159
163
  let startFrom = 'test';
160
164
 
165
+ function isTrustedForCurrentUrl() {
166
+ return trustedHostname !== null && hostnameOf(url) === trustedHostname;
167
+ }
168
+
169
+ async function attemptConnection() {
170
+ if (isJira) {
171
+ const testEnv = {
172
+ JIRA_BASE_URL: url,
173
+ JIRA_EMAIL: email,
174
+ JIRA_API_TOKEN: auth !== 'pat' ? token : '',
175
+ JIRA_PAT: auth === 'pat' ? token : '',
176
+ };
177
+ await fetchCurrentUser({ env: testEnv, apiVersion: auth === 'cloud' ? 3 : 2, allowPrivateIp: isTrustedForCurrentUrl() });
178
+ } else {
179
+ await resolveAdapter({ baseUrl: url, auth: trackerType, apiToken: token }).fetchCurrentUser();
180
+ }
181
+ }
182
+
161
183
  setupLoop: while (true) {
162
184
  // Re-prompt URL + auth (on retry with 'url' option)
163
185
  if (startFrom === 'url') {
@@ -216,17 +238,7 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
216
238
  session.spin('Testing connection...');
217
239
 
218
240
  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
- }
241
+ await attemptConnection();
230
242
  session.connected();
231
243
  connected = true;
232
244
  break setupLoop;
@@ -234,6 +246,15 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
234
246
  session.failed();
235
247
  const classified = classifyError(err, { baseUrl: url, profileName: target });
236
248
  session.footer(classified.message, 'error', classified.hint);
249
+
250
+ if (classified.privateIpBlocked) {
251
+ const trust = await promptYN('Trust this connection?', { stream });
252
+ if (trust) {
253
+ trustedHostname = hostnameOf(url);
254
+ startFrom = 'test';
255
+ continue setupLoop;
256
+ }
257
+ }
237
258
  }
238
259
 
239
260
  stream.write(`\n ${s.dim('What would you like to do?')}\n\n`);
@@ -326,7 +347,7 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
326
347
  JIRA_API_TOKEN: auth !== 'pat' ? token : '',
327
348
  JIRA_PAT: auth === 'pat' ? token : '',
328
349
  };
329
- available = await fetchStatuses({ env: validateEnv, apiVersion: auth === 'cloud' ? 3 : 2 });
350
+ available = await fetchStatuses({ env: validateEnv, apiVersion: auth === 'cloud' ? 3 : 2, allowPrivateIp: isTrustedForCurrentUrl() });
330
351
  } else {
331
352
  available = await resolveAdapter({ baseUrl: url, auth: trackerType, apiToken: token }).fetchStatuses();
332
353
  }
@@ -397,6 +418,8 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
397
418
  else delete updated.projectPaths;
398
419
  if (cacheTtl && cacheTtl !== DEFAULT_BRIEF_TTL) updated.cacheTtl = cacheTtl;
399
420
  else delete updated.cacheTtl;
421
+ if (isTrustedForCurrentUrl()) updated.allowPrivateIp = true;
422
+ else delete updated.allowPrivateIp;
400
423
 
401
424
  const credData = (token !== existingToken)
402
425
  ? (auth === 'pat' ? { pat: token } : { apiToken: token })
@@ -11,21 +11,35 @@ import { homedir } from 'node:os';
11
11
  /** Canonical config directory: ~/.ticketlens */
12
12
  export const DEFAULT_CONFIG_DIR = join(homedir(), '.ticketlens');
13
13
 
14
- /** Read package.json version once per process, memoized.
14
+ /** Read package.json once per process, memoized.
15
15
  * Uses realpathSync to resolve symlinks before navigating, so the correct
16
- * package.json is found whether the module is loaded via a symlink or directly.
16
+ * package.json is found whether the module is loaded via a symlink (e.g.
17
+ * the npm-managed `tl` bin) or directly.
17
18
  */
18
- let _version;
19
- export function getVersion() {
20
- if (_version) return _version;
19
+ let _pkg;
20
+ function _readPkg() {
21
+ if (_pkg) return _pkg;
21
22
  try {
22
23
  const realDir = dirname(realpathSync(fileURLToPath(import.meta.url)));
23
- const pkg = JSON.parse(readFileSync(join(realDir, '..', '..', '..', '..', 'package.json'), 'utf8'));
24
- _version = pkg.version || '0.0.0';
24
+ _pkg = JSON.parse(readFileSync(join(realDir, '..', '..', '..', '..', 'package.json'), 'utf8'));
25
25
  } catch {
26
- _version = '0.0.0';
26
+ _pkg = {};
27
27
  }
28
- return _version;
28
+ return _pkg;
29
+ }
30
+
31
+ export function getVersion() {
32
+ return _readPkg().version || '0.0.0';
33
+ }
34
+
35
+ /** Metadata used by the first-run wordmark banner. */
36
+ export function getPackageMeta() {
37
+ const pkg = _readPkg();
38
+ return {
39
+ version: pkg.version || '0.0.0',
40
+ author: pkg.author || 'unknown',
41
+ repository: typeof pkg.repository === 'string' ? pkg.repository : (pkg.repository?.url || ''),
42
+ };
29
43
  }
30
44
 
31
45
  /** Human-readable relative time from an ISO date string. */
@@ -54,6 +68,12 @@ export function stripCr(str) {
54
68
  return str.replace(/\r/g, '');
55
69
  }
56
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
+
57
77
  /**
58
78
  * Build the env-like object expected by jira-client functions.
59
79
  * @param {{ baseUrl: string, pat?: string, email?: string, apiToken?: string }} conn