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
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests every configured profile's connection, one at a time with a spinner
|
|
3
|
+
* per profile — the same pattern init-wizard.mjs/config-wizard.mjs already
|
|
4
|
+
* use for a single profile, just looped across all of them.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createSession } from './banner.mjs';
|
|
8
|
+
import { classifyError } from './error-classifier.mjs';
|
|
9
|
+
import { resolveAdapter } from './resolve-adapter.mjs';
|
|
10
|
+
import { loadProfiles, loadCredentials } from './profile-resolver.mjs';
|
|
11
|
+
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
12
|
+
|
|
13
|
+
export async function testConnections({
|
|
14
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
15
|
+
stream = process.stderr,
|
|
16
|
+
resolveAdapterFn = resolveAdapter,
|
|
17
|
+
} = {}) {
|
|
18
|
+
const config = loadProfiles(configDir);
|
|
19
|
+
const names = config ? Object.keys(config.profiles) : [];
|
|
20
|
+
const creds = loadCredentials(configDir);
|
|
21
|
+
const results = [];
|
|
22
|
+
|
|
23
|
+
for (const name of names) {
|
|
24
|
+
const profile = config.profiles[name];
|
|
25
|
+
const profileCreds = creds[name] || {};
|
|
26
|
+
const conn = {
|
|
27
|
+
baseUrl: profile.baseUrl,
|
|
28
|
+
auth: profile.auth,
|
|
29
|
+
email: profile.email,
|
|
30
|
+
apiToken: profileCreds.apiToken,
|
|
31
|
+
pat: profileCreds.pat,
|
|
32
|
+
allowPrivateIp: profile.allowPrivateIp,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const session = createSession(
|
|
36
|
+
{ baseUrl: conn.baseUrl, profileName: name, email: conn.email || undefined, pat: conn.auth === 'pat' ? conn.pat : undefined },
|
|
37
|
+
{ stream },
|
|
38
|
+
);
|
|
39
|
+
session.spin('Testing connection...');
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
await resolveAdapterFn(conn).fetchCurrentUser();
|
|
43
|
+
session.connected();
|
|
44
|
+
results.push({ name, ok: true });
|
|
45
|
+
} catch (err) {
|
|
46
|
+
session.failed();
|
|
47
|
+
const classified = classifyError(err, { baseUrl: conn.baseUrl, profileName: name });
|
|
48
|
+
session.footer(classified.message, 'error', classified.hint);
|
|
49
|
+
results.push({ name, ok: false, error: classified.message });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { results, failedCount: results.filter(r => !r.ok).length };
|
|
54
|
+
}
|
|
@@ -18,6 +18,15 @@ export function classifyError(err, { baseUrl, profileName } = {}) {
|
|
|
18
18
|
const profile = profileName || 'your profile';
|
|
19
19
|
const code = getNetworkCode(err);
|
|
20
20
|
|
|
21
|
+
if (err.code === 'PRIVATE_IP_BLOCKED') {
|
|
22
|
+
const address = err.blockedAddress ? ` (${err.blockedAddress})` : '';
|
|
23
|
+
return {
|
|
24
|
+
message: `${err.blockedHostname} resolves to a private address${address}`,
|
|
25
|
+
hint: "This is expected if you're on a VPN connecting to an on-prem Jira instance. It could also mean something is wrong.",
|
|
26
|
+
privateIpBlocked: true,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
21
30
|
// Network-level errors (fetch threw before getting an HTTP response)
|
|
22
31
|
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') {
|
|
23
32
|
return {
|
|
@@ -3,12 +3,11 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { createStyler } from './ansi.mjs';
|
|
6
|
-
import {
|
|
6
|
+
import { renderWordmark } from './wordmark.mjs';
|
|
7
7
|
|
|
8
8
|
export function printHelp({ stream = process.stdout } = {}) {
|
|
9
9
|
const isTTY = stream.isTTY;
|
|
10
10
|
const s = createStyler({ isTTY });
|
|
11
|
-
const v = getVersion();
|
|
12
11
|
|
|
13
12
|
// Column targets (visible chars):
|
|
14
13
|
// USAGE: command portion = 36, so descriptions always start at the same column
|
|
@@ -18,12 +17,11 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
18
17
|
// ANSI codes (s.cyan, s.dim) add invisible bytes — they do NOT affect visible width.
|
|
19
18
|
|
|
20
19
|
const lines = [
|
|
21
|
-
'',
|
|
22
|
-
` ${s.bold(s.brand('◆ TicketLens'))} ${s.dim(`v${v}`)}`,
|
|
23
|
-
` ${s.dim('Stop tab-switching. Start building.')}`,
|
|
24
20
|
'',
|
|
25
21
|
` ${s.bold('USAGE')}`,
|
|
26
22
|
'',
|
|
23
|
+
` ${s.brand('tl')} ${s.dim('<anything>')} Shorthand for ${s.brand('ticketlens')}`,
|
|
24
|
+
'',
|
|
27
25
|
// Alignment targets (visible chars before description):
|
|
28
26
|
// Groups 1–2 (auth/setup): column 40 (command portion ≤ 24)
|
|
29
27
|
// Group 3 (daily use): column 42 (longest: "review [--branch=BRANCH]" = 39)
|
|
@@ -56,6 +54,10 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
56
54
|
` ${s.brand('ticketlens')} cloud-keys ${s.dim('[add|remove|list|test]')} Manage your encrypted AI provider keys ${s.dim('[Pro]')}`,
|
|
57
55
|
` ${s.brand('ticketlens')} update-skill ${s.dim('[--dry-run]')} Update /jtb skill in Claude Code and other AI assistants`,
|
|
58
56
|
'',
|
|
57
|
+
` ${s.bold('GLOBAL OPTIONS')}`,
|
|
58
|
+
'',
|
|
59
|
+
` ${s.brand('--no-input')} Force non-interactive behavior even in a terminal`,
|
|
60
|
+
'',
|
|
59
61
|
` ${s.bold('FETCH OPTIONS')}`,
|
|
60
62
|
'',
|
|
61
63
|
// visible widths: "--profile=NAME"=14, "--depth=N"=9, "--plain"=7, "--styled"=8,
|
|
@@ -125,7 +127,7 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
125
127
|
'',
|
|
126
128
|
];
|
|
127
129
|
|
|
128
|
-
stream.write(lines.join('\n') + '\n');
|
|
130
|
+
stream.write(renderWordmark({ stream }) + lines.join('\n') + '\n');
|
|
129
131
|
}
|
|
130
132
|
|
|
131
133
|
export function printFetchHelp({ stream = process.stdout } = {}) {
|
|
@@ -14,8 +14,10 @@ import { loadProfiles, saveProfile, saveDefault } from './profile-resolver.mjs';
|
|
|
14
14
|
import { resolveAdapter } from './resolve-adapter.mjs';
|
|
15
15
|
import { promptSelect } from './select-prompt.mjs';
|
|
16
16
|
import { runSwitch } from './profile-switcher.mjs';
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
17
|
+
import { renderWordmark } from './wordmark.mjs';
|
|
18
|
+
import { printQuickStart } from './quick-start-panel.mjs';
|
|
19
|
+
import { DEFAULT_CONFIG_DIR, hostnameOf } from './config.mjs';
|
|
20
|
+
import { SERVER_AUTH_TYPES, promptText, promptSecret, promptYN } from './prompt-helpers.mjs';
|
|
19
21
|
|
|
20
22
|
const RETRY_OPTIONS = [
|
|
21
23
|
{ label: 'Retry', sublabel: 'Try again — same credentials (e.g. VPN just connected)', value: 'retry' },
|
|
@@ -51,7 +53,7 @@ async function probeProtocol(host, { stream, s }) {
|
|
|
51
53
|
|
|
52
54
|
// ── Main wizard ───────────────────────────────────────────────────────────────
|
|
53
55
|
|
|
54
|
-
export async function run({ configDir = DEFAULT_CONFIG_DIR } = {}) {
|
|
56
|
+
export async function run({ configDir = DEFAULT_CONFIG_DIR, showBanner = true, showQuickStart = true } = {}) {
|
|
55
57
|
const stream = process.stderr;
|
|
56
58
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
57
59
|
|
|
@@ -65,27 +67,22 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR } = {}) {
|
|
|
65
67
|
process.on('SIGINT', onSigint);
|
|
66
68
|
|
|
67
69
|
try {
|
|
68
|
-
await _run({ configDir, stream, s });
|
|
70
|
+
await _run({ configDir, stream, s, showBanner, showQuickStart });
|
|
69
71
|
} finally {
|
|
70
72
|
process.removeListener('SIGINT', onSigint);
|
|
71
73
|
}
|
|
72
74
|
}
|
|
73
75
|
|
|
74
|
-
async function _run({ configDir, stream, s }) {
|
|
75
|
-
// Welcome
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
stream.write(bc('╭' + '─'.repeat(innerWidth) + '╮') + '\n');
|
|
84
|
-
for (const line of headerLines) {
|
|
85
|
-
const pad = innerWidth - visLen(line) - 1;
|
|
86
|
-
stream.write(bc('│') + ' ' + line + ' '.repeat(Math.max(0, pad)) + bc('│') + '\n');
|
|
76
|
+
async function _run({ configDir, stream, s, showBanner, showQuickStart }) {
|
|
77
|
+
// Welcome banner — same wordmark as --help/--version, so a brand-new user's
|
|
78
|
+
// very first output looks like the same product (Phase 1's renderWordmark()
|
|
79
|
+
// was built reusable for exactly this; not duplicating the art). Suppressed
|
|
80
|
+
// when invoked from the onboarding hub, which already showed its own banner
|
|
81
|
+
// — otherwise the user sees it twice in the fresh-install flow.
|
|
82
|
+
if (showBanner) {
|
|
83
|
+
stream.write(renderWordmark({ stream }));
|
|
84
|
+
stream.write(`\n${s.dim("Let's configure your tracker connection.")}\n`);
|
|
87
85
|
}
|
|
88
|
-
stream.write(bc('╰' + '─'.repeat(innerWidth) + '╯') + '\n');
|
|
89
86
|
|
|
90
87
|
let addedCount = 0;
|
|
91
88
|
let addAnother = true;
|
|
@@ -331,6 +328,12 @@ async function _run({ configDir, stream, s }) {
|
|
|
331
328
|
let baseUrl = '', authType = '', email = '', token = '';
|
|
332
329
|
let env = {}, apiVersion = 2;
|
|
333
330
|
let startFrom = 'url';
|
|
331
|
+
// Trust is bound to the exact hostname it was granted for — never a flat
|
|
332
|
+
// per-run boolean. Choosing "Edit from URL" after granting trust for one
|
|
333
|
+
// host must re-trigger the private-IP block and a fresh confirmation
|
|
334
|
+
// for the new host, not silently inherit trust.
|
|
335
|
+
let trustedHostname = null;
|
|
336
|
+
const isTrustedForCurrentUrl = () => trustedHostname !== null && hostnameOf(baseUrl) === trustedHostname;
|
|
334
337
|
|
|
335
338
|
setupLoop: while (true) {
|
|
336
339
|
// ── URL + auth type ────────────────────────────────────────────────────
|
|
@@ -435,7 +438,7 @@ async function _run({ configDir, stream, s }) {
|
|
|
435
438
|
session.spin('Testing connection...');
|
|
436
439
|
|
|
437
440
|
try {
|
|
438
|
-
await fetchCurrentUser({ env, apiVersion });
|
|
441
|
+
await fetchCurrentUser({ env, apiVersion, allowPrivateIp: isTrustedForCurrentUrl() });
|
|
439
442
|
session.connected();
|
|
440
443
|
connected = true;
|
|
441
444
|
break setupLoop;
|
|
@@ -443,6 +446,15 @@ async function _run({ configDir, stream, s }) {
|
|
|
443
446
|
session.failed();
|
|
444
447
|
const classified = classifyError(err, { baseUrl, profileName });
|
|
445
448
|
session.footer(classified.message, 'error', classified.hint);
|
|
449
|
+
|
|
450
|
+
if (classified.privateIpBlocked) {
|
|
451
|
+
const trust = await promptYN('Trust this connection?', { stream });
|
|
452
|
+
if (trust) {
|
|
453
|
+
trustedHostname = hostnameOf(baseUrl);
|
|
454
|
+
startFrom = 'retry';
|
|
455
|
+
continue setupLoop;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
446
458
|
}
|
|
447
459
|
|
|
448
460
|
// ── Retry options ──────────────────────────────────────────────────────
|
|
@@ -506,7 +518,7 @@ async function _run({ configDir, stream, s }) {
|
|
|
506
518
|
|
|
507
519
|
stream.write(` ${s.dim('Validating statuses...')}\n`);
|
|
508
520
|
try {
|
|
509
|
-
const available = await fetchStatuses({ env, apiVersion });
|
|
521
|
+
const available = await fetchStatuses({ env, apiVersion, allowPrivateIp: isTrustedForCurrentUrl() });
|
|
510
522
|
const lowerMap = new Map(available.map(n => [n.toLowerCase(), n]));
|
|
511
523
|
stream.write('\x1b[A\r\x1b[2K'); // clear "Validating..." line
|
|
512
524
|
const corrected = [];
|
|
@@ -543,6 +555,7 @@ async function _run({ configDir, stream, s }) {
|
|
|
543
555
|
...(email ? { email } : {}),
|
|
544
556
|
...(ticketPrefixes.length > 0 ? { ticketPrefixes } : {}),
|
|
545
557
|
...(projectPaths.length > 0 ? { projectPaths } : {}),
|
|
558
|
+
...(isTrustedForCurrentUrl() ? { allowPrivateIp: true } : {}),
|
|
546
559
|
triageStatuses,
|
|
547
560
|
};
|
|
548
561
|
const credData = authType === 'pat' ? { pat: token } : { apiToken: token };
|
|
@@ -575,25 +588,9 @@ async function _run({ configDir, stream, s }) {
|
|
|
575
588
|
const profileWord = addedCount === 1 ? '1 profile' : `${addedCount} profiles`;
|
|
576
589
|
stream.write(`\n ${s.green('✔')} ${profileWord} configured.\n\n`);
|
|
577
590
|
|
|
578
|
-
//
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
['ticketlens --help', 'Full command reference'],
|
|
584
|
-
];
|
|
585
|
-
const cmdWidth = cmds.reduce((max, [c]) => Math.max(max, c.length), 0);
|
|
586
|
-
const cmdRows = cmds.map(([cmd, desc]) =>
|
|
587
|
-
` ${s.bold(s.cyan(cmd.padEnd(cmdWidth)))} ${s.dim(desc)}`
|
|
588
|
-
);
|
|
589
|
-
const QTITLE = ' Quick start ';
|
|
590
|
-
const contentWidth = cmdRows.reduce((max, r) => Math.max(max, visLen(r)), 0);
|
|
591
|
-
const qWidth = Math.max(contentWidth + 2, QTITLE.length + 4);
|
|
592
|
-
const qPad = (r) => ' ' + r + ' '.repeat(Math.max(0, qWidth - visLen(r) - 1));
|
|
593
|
-
const qTitleFill = qWidth - 1 - QTITLE.length;
|
|
594
|
-
stream.write(bc('╭') + bc('─') + s.bold(s.cyan(QTITLE)) + bc('─'.repeat(Math.max(0, qTitleFill))) + bc('╮') + '\n');
|
|
595
|
-
stream.write(bc('│') + qPad('') + bc('│') + '\n');
|
|
596
|
-
for (const r of cmdRows) stream.write(bc('│') + qPad(r) + bc('│') + '\n');
|
|
597
|
-
stream.write(bc('│') + qPad('') + bc('│') + '\n');
|
|
598
|
-
stream.write(bc('╰') + bc('─'.repeat(qWidth)) + bc('╯') + '\n\n');
|
|
591
|
+
// Suppressed when invoked from the onboarding hub, which owns showing this
|
|
592
|
+
// exactly once after its own menu loop exits — otherwise the user sees it twice.
|
|
593
|
+
if (showQuickStart) {
|
|
594
|
+
printQuickStart({ stream, s });
|
|
595
|
+
}
|
|
599
596
|
}
|
|
@@ -125,7 +125,14 @@ function normalizeHostname(hostname) {
|
|
|
125
125
|
return hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
|
|
128
|
+
/**
|
|
129
|
+
* @param {string} url
|
|
130
|
+
* @param {boolean} [allowPrivateIp] - skip the private/internal blocklist check
|
|
131
|
+
* for THIS hostname. Set only via explicit interactive user confirmation
|
|
132
|
+
* during setup, persisted per-profile — never from synced/network data
|
|
133
|
+
* (see team-jira-sync.mjs). HTTPS enforcement is never skippable.
|
|
134
|
+
*/
|
|
135
|
+
export function validateBaseUrl(url, allowPrivateIp = false) {
|
|
129
136
|
let parsed;
|
|
130
137
|
try { parsed = new URL(url); } catch {
|
|
131
138
|
throw new Error(`JIRA_BASE_URL is not a valid URL: ${url}`);
|
|
@@ -133,9 +140,14 @@ export function validateBaseUrl(url) {
|
|
|
133
140
|
if (parsed.protocol !== 'https:') {
|
|
134
141
|
throw new Error(`JIRA_BASE_URL must use HTTPS (got ${parsed.protocol})`);
|
|
135
142
|
}
|
|
143
|
+
if (allowPrivateIp) return;
|
|
136
144
|
const hostname = normalizeHostname(parsed.hostname);
|
|
137
145
|
if (BLOCKED_PATTERNS.some(re => re.test(hostname))) {
|
|
138
|
-
|
|
146
|
+
const err = new Error(`JIRA_BASE_URL hostname is blocked (${hostname})`);
|
|
147
|
+
err.code = 'PRIVATE_IP_BLOCKED';
|
|
148
|
+
err.blockedHostname = hostname;
|
|
149
|
+
err.blockedAddress = null;
|
|
150
|
+
throw err;
|
|
139
151
|
}
|
|
140
152
|
}
|
|
141
153
|
|
|
@@ -189,9 +201,17 @@ const dnsValidationCaches = new WeakMap();
|
|
|
189
201
|
* `lookup` is null when the fetcher is mocked (tests) — no socket opens, so
|
|
190
202
|
* there is nothing to protect. Production always uses globalThis.fetch, which
|
|
191
203
|
* always gets the real resolver (see defaultLookupFor).
|
|
204
|
+
*
|
|
205
|
+
* `allowPrivateIp` skips the lookup entirely (not just the block) when a
|
|
206
|
+
* profile has an explicit, user-confirmed trust exception for this exact
|
|
207
|
+
* hostname — set only during interactive setup, never from synced/network
|
|
208
|
+
* data. Skipping the resolution outright (rather than resolving-then-
|
|
209
|
+
* ignoring) avoids the DNS round-trip and sidesteps any question of how the
|
|
210
|
+
* per-lookup-fn cache should key on a flag that no real caller varies
|
|
211
|
+
* concurrently for the same hostname.
|
|
192
212
|
*/
|
|
193
|
-
export async function validateResolvedHost(hostname, lookup) {
|
|
194
|
-
if (!lookup) return;
|
|
213
|
+
export async function validateResolvedHost(hostname, lookup, allowPrivateIp = false) {
|
|
214
|
+
if (!lookup || allowPrivateIp) return;
|
|
195
215
|
let cache = dnsValidationCaches.get(lookup);
|
|
196
216
|
if (!cache) {
|
|
197
217
|
cache = new Map();
|
|
@@ -202,7 +222,11 @@ export async function validateResolvedHost(hostname, lookup) {
|
|
|
202
222
|
const addresses = await lookup(hostname, { all: true });
|
|
203
223
|
for (const { address } of addresses) {
|
|
204
224
|
if (BLOCKED_PATTERNS.some(re => re.test(address))) {
|
|
205
|
-
|
|
225
|
+
const err = new Error(`Hostname ${hostname} resolves to a blocked address (${address}) — refusing to connect`);
|
|
226
|
+
err.code = 'PRIVATE_IP_BLOCKED';
|
|
227
|
+
err.blockedHostname = hostname;
|
|
228
|
+
err.blockedAddress = address;
|
|
229
|
+
throw err;
|
|
206
230
|
}
|
|
207
231
|
}
|
|
208
232
|
})();
|
|
@@ -223,8 +247,8 @@ export async function validateResolvedHost(hostname, lookup) {
|
|
|
223
247
|
* Error message carries the redirect target's HOSTNAME only — Location values
|
|
224
248
|
* can contain presigned tokens in path/query.
|
|
225
249
|
*/
|
|
226
|
-
export async function guardedFetch(url, fetchOpts, { fetcher, lookup }) {
|
|
227
|
-
await validateResolvedHost(new URL(url).hostname, lookup);
|
|
250
|
+
export async function guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp = false }) {
|
|
251
|
+
await validateResolvedHost(new URL(url).hostname, lookup, allowPrivateIp);
|
|
228
252
|
const response = await fetcher(url, { ...fetchOpts, redirect: 'manual' });
|
|
229
253
|
if (response.status >= 300 && response.status < 400) {
|
|
230
254
|
let target = 'unknown host';
|
|
@@ -248,15 +272,15 @@ export function buildAuthHeader(env) {
|
|
|
248
272
|
}
|
|
249
273
|
|
|
250
274
|
export async function fetchCurrentUser(opts = {}) {
|
|
251
|
-
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000 } = opts;
|
|
252
|
-
validateBaseUrl(env.JIRA_BASE_URL);
|
|
275
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
|
|
276
|
+
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
253
277
|
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
254
278
|
const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
|
|
255
279
|
|
|
256
280
|
const url = `${baseUrl}/rest/api/${apiVersion}/myself`;
|
|
257
281
|
const fetchOpts = { headers };
|
|
258
282
|
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
259
|
-
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
|
|
283
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
|
|
260
284
|
|
|
261
285
|
if (!response.ok) {
|
|
262
286
|
const err = new Error(`Jira API error ${response.status} fetching current user`);
|
|
@@ -274,15 +298,15 @@ export async function fetchCurrentUser(opts = {}) {
|
|
|
274
298
|
}
|
|
275
299
|
|
|
276
300
|
export async function fetchStatuses(opts = {}) {
|
|
277
|
-
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000 } = opts;
|
|
278
|
-
validateBaseUrl(env.JIRA_BASE_URL);
|
|
301
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
|
|
302
|
+
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
279
303
|
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
280
304
|
const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
|
|
281
305
|
|
|
282
306
|
const url = `${baseUrl}/rest/api/${apiVersion}/status`;
|
|
283
307
|
const fetchOpts = { headers };
|
|
284
308
|
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
285
|
-
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
|
|
309
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
|
|
286
310
|
|
|
287
311
|
if (!response.ok) {
|
|
288
312
|
const err = new Error(`Jira API error ${response.status} fetching statuses`);
|
|
@@ -295,8 +319,8 @@ export async function fetchStatuses(opts = {}) {
|
|
|
295
319
|
}
|
|
296
320
|
|
|
297
321
|
export async function searchTickets(jql, opts = {}) {
|
|
298
|
-
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), maxResults = 50, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false } = opts;
|
|
299
|
-
validateBaseUrl(env.JIRA_BASE_URL);
|
|
322
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), maxResults = 50, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false, allowPrivateIp = false } = opts;
|
|
323
|
+
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
300
324
|
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
301
325
|
const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
|
|
302
326
|
|
|
@@ -307,7 +331,7 @@ export async function searchTickets(jql, opts = {}) {
|
|
|
307
331
|
const url = `${baseUrl}${endpoint}?${params}`;
|
|
308
332
|
const fetchOpts = { headers };
|
|
309
333
|
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
310
|
-
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
|
|
334
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
|
|
311
335
|
|
|
312
336
|
if (!response.ok) {
|
|
313
337
|
let detail = '';
|
|
@@ -323,15 +347,15 @@ export async function searchTickets(jql, opts = {}) {
|
|
|
323
347
|
}
|
|
324
348
|
|
|
325
349
|
export async function fetchRemoteLinks(ticketKey, opts = {}) {
|
|
326
|
-
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000 } = opts;
|
|
327
|
-
validateBaseUrl(env.JIRA_BASE_URL);
|
|
350
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
|
|
351
|
+
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
328
352
|
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
329
353
|
const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}/remotelink`;
|
|
330
354
|
|
|
331
355
|
const fetchOpts = { headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' } };
|
|
332
356
|
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
333
357
|
|
|
334
|
-
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
|
|
358
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
|
|
335
359
|
if (!response.ok) return [];
|
|
336
360
|
|
|
337
361
|
const raw = await response.json();
|
|
@@ -341,8 +365,8 @@ export async function fetchRemoteLinks(ticketKey, opts = {}) {
|
|
|
341
365
|
}
|
|
342
366
|
|
|
343
367
|
export async function fetchTicket(ticketKey, opts = {}) {
|
|
344
|
-
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), depth = 1, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false, _visited = new Set(), _currentDepth = 0 } = opts;
|
|
345
|
-
validateBaseUrl(env.JIRA_BASE_URL);
|
|
368
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), depth = 1, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false, allowPrivateIp = false, _visited = new Set(), _currentDepth = 0 } = opts;
|
|
369
|
+
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
346
370
|
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
347
371
|
const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
|
|
348
372
|
|
|
@@ -350,7 +374,7 @@ export async function fetchTicket(ticketKey, opts = {}) {
|
|
|
350
374
|
const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}${expand}`;
|
|
351
375
|
const fetchOpts = { headers };
|
|
352
376
|
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
353
|
-
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
|
|
377
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
|
|
354
378
|
|
|
355
379
|
if (!response.ok) {
|
|
356
380
|
const err = new Error(`Jira API error ${response.status} fetching ${ticketKey}`);
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ticketlens login — connect the CLI to a TicketLens account.
|
|
3
|
+
* Extracted out of bin/ticketlens.mjs so the onboarding hub can reuse it
|
|
4
|
+
* without duplicating the browser/manual flow or token verification.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createStyler } from './ansi.mjs';
|
|
8
|
+
import { promptSecret } from './prompt-helpers.mjs';
|
|
9
|
+
import { browserLogin } from './browser-login.mjs';
|
|
10
|
+
import { saveCliToken } from './cli-auth.mjs';
|
|
11
|
+
import { getApiBase, getConsoleBase } from './sync.mjs';
|
|
12
|
+
import { applyTeamConfigOnLogin } from './team-jira-sync.mjs';
|
|
13
|
+
|
|
14
|
+
export async function runLogin({
|
|
15
|
+
manual = false,
|
|
16
|
+
stream = process.stderr,
|
|
17
|
+
showSyncHint = true,
|
|
18
|
+
browserLoginFn = browserLogin,
|
|
19
|
+
fetchFn = globalThis.fetch,
|
|
20
|
+
promptSecretFn = promptSecret,
|
|
21
|
+
saveCliTokenFn = saveCliToken,
|
|
22
|
+
applyTeamConfigOnLoginFn = applyTeamConfigOnLogin,
|
|
23
|
+
} = {}) {
|
|
24
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
25
|
+
|
|
26
|
+
let token;
|
|
27
|
+
|
|
28
|
+
if (manual) {
|
|
29
|
+
// ── manual paste flow (CI / headless environments) ──────────────────
|
|
30
|
+
stream.write(`\n ${s.bold('TicketLens Login')}\n`);
|
|
31
|
+
stream.write(` ${s.dim('─'.repeat(44))}\n`);
|
|
32
|
+
stream.write(` ${s.dim(`Generate a CLI token at ${s.cyan(`${getConsoleBase()}/console/account`)}`)}\n`);
|
|
33
|
+
stream.write(` ${s.dim('then paste it below.')}\n\n`);
|
|
34
|
+
|
|
35
|
+
token = await promptSecretFn(`CLI Token ${s.dim('(tl_…)')}:`, { stream });
|
|
36
|
+
if (!token.startsWith('tl_')) {
|
|
37
|
+
stream.write(` ${s.red('✖')} Token must start with ${s.dim('tl_')}\n`);
|
|
38
|
+
process.exitCode = 1;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
} else {
|
|
42
|
+
// ── browser flow (default) ────────────────────────────────────────
|
|
43
|
+
stream.write(`\n ${s.bold('TicketLens Login')}\n`);
|
|
44
|
+
stream.write(` ${s.dim('─'.repeat(44))}\n`);
|
|
45
|
+
stream.write(` Opening browser to authorize…\n\n`);
|
|
46
|
+
stream.write(` ${s.dim('○ Waiting for authorization (120s)…')}\n`);
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
token = await browserLoginFn();
|
|
50
|
+
} catch (err) {
|
|
51
|
+
const cancelled = err.message === 'Authorization cancelled';
|
|
52
|
+
stream.write(`\x1b[A\r\x1b[2K ${s.red('✖')} ${cancelled ? 'Login cancelled.' : err.message}\n`);
|
|
53
|
+
if (!cancelled) {
|
|
54
|
+
stream.write(`\n ${s.dim(`Try ${s.cyan('ticketlens login --manual')} to paste a token instead.`)}\n\n`);
|
|
55
|
+
}
|
|
56
|
+
process.exitCode = cancelled ? 0 : 1;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── verify token against API (both flows) ─────────────────────────
|
|
62
|
+
stream.write(`\n ${s.dim('○ Verifying token…')}\n`);
|
|
63
|
+
let res;
|
|
64
|
+
try {
|
|
65
|
+
res = await fetchFn(`${getApiBase()}/v1/profiles`, {
|
|
66
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
67
|
+
signal: AbortSignal.timeout(15000),
|
|
68
|
+
});
|
|
69
|
+
} catch {
|
|
70
|
+
stream.write(`\x1b[A\r\x1b[2K ${s.red('✖')} Could not reach ${getApiBase()} — check your connection.\n`);
|
|
71
|
+
process.exitCode = 1;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (res.status === 401) {
|
|
76
|
+
stream.write(`\x1b[A\r\x1b[2K ${s.red('✖')} Invalid token — check the value and try again.\n`);
|
|
77
|
+
process.exitCode = 1;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (!res.ok) {
|
|
81
|
+
stream.write(`\x1b[A\r\x1b[2K ${s.red('✖')} Server returned ${res.status}. Try again later.\n`);
|
|
82
|
+
process.exitCode = 1;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
saveCliTokenFn(token);
|
|
87
|
+
stream.write(`\x1b[A\r\x1b[2K ${s.green('✔')} Logged in.\n`);
|
|
88
|
+
|
|
89
|
+
// Flow 1: pull team Jira config for Pro/Team members (silently skipped for Free)
|
|
90
|
+
const tcLogin = await applyTeamConfigOnLoginFn().catch(() => null);
|
|
91
|
+
if (tcLogin?.ok) {
|
|
92
|
+
stream.write(` ${s.dim(`○ Team Jira config applied for ${s.cyan(tcLogin.groupName)}.`)}\n`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (showSyncHint) {
|
|
96
|
+
stream.write(`\n Run ${s.cyan('ticketlens sync')} to pull your connections.\n\n`);
|
|
97
|
+
}
|
|
98
|
+
}
|