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.
- package/README.md +3 -1
- package/bin/ticketlens.mjs +47 -117
- package/package.json +3 -2
- package/scripts/postinstall.mjs +34 -0
- package/skills/jtb/SKILL.md +2 -2
- package/skills/jtb/scripts/fetch-ticket.mjs +2 -1
- package/skills/jtb/scripts/lib/adapters/jira-adapter.mjs +4 -4
- package/skills/jtb/scripts/lib/alias-status.mjs +73 -0
- package/skills/jtb/scripts/lib/api-utils.mjs +1 -1
- package/skills/jtb/scripts/lib/attachment-downloader.mjs +10 -3
- package/skills/jtb/scripts/lib/cli.mjs +1 -1
- package/skills/jtb/scripts/lib/config-wizard.mjs +36 -13
- package/skills/jtb/scripts/lib/config.mjs +29 -9
- package/skills/jtb/scripts/lib/connection-tester.mjs +54 -0
- package/skills/jtb/scripts/lib/error-classifier.mjs +9 -0
- package/skills/jtb/scripts/lib/help.mjs +8 -6
- package/skills/jtb/scripts/lib/init-wizard.mjs +38 -41
- package/skills/jtb/scripts/lib/jira-client.mjs +46 -22
- package/skills/jtb/scripts/lib/login-flow.mjs +98 -0
- package/skills/jtb/scripts/lib/onboarding.mjs +254 -0
- package/skills/jtb/scripts/lib/profile-resolver.mjs +1 -0
- package/skills/jtb/scripts/lib/profile-switcher.mjs +2 -1
- package/skills/jtb/scripts/lib/prompt-helpers.mjs +41 -2
- package/skills/jtb/scripts/lib/quick-start-panel.mjs +32 -0
- package/skills/jtb/scripts/lib/run-stats.mjs +2 -1
- package/skills/jtb/scripts/lib/select-prompt.mjs +4 -3
- package/skills/jtb/scripts/lib/setup-state.mjs +35 -0
- package/skills/jtb/scripts/lib/sync.mjs +48 -0
- package/skills/jtb/scripts/lib/team-jira-sync.mjs +14 -2
- 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
|
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -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,
|
|
33
|
-
import {
|
|
34
|
-
import { syncProfiles,
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
529
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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/",
|
package/scripts/postinstall.mjs
CHANGED
|
@@ -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
|
+
}
|
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
19
|
-
|
|
20
|
-
if (
|
|
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
|
-
|
|
24
|
-
_version = pkg.version || '0.0.0';
|
|
24
|
+
_pkg = JSON.parse(readFileSync(join(realDir, '..', '..', '..', '..', 'package.json'), 'utf8'));
|
|
25
25
|
} catch {
|
|
26
|
-
|
|
26
|
+
_pkg = {};
|
|
27
27
|
}
|
|
28
|
-
return
|
|
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
|