ticketlens 0.10.18 → 0.12.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 +1 -1
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +1 -1
- 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/attachment-downloader.mjs +10 -3
- package/skills/jtb/scripts/lib/config-wizard.mjs +130 -79
- package/skills/jtb/scripts/lib/config.mjs +6 -0
- package/skills/jtb/scripts/lib/connection-tester.mjs +1 -0
- package/skills/jtb/scripts/lib/error-classifier.mjs +9 -0
- package/skills/jtb/scripts/lib/init-wizard.mjs +78 -44
- package/skills/jtb/scripts/lib/jira-client.mjs +90 -22
- 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 +1 -2
- package/skills/jtb/scripts/lib/select-prompt.mjs +101 -7
- package/skills/jtb/scripts/lib/team-jira-sync.mjs +14 -2
- package/skills/jtb/scripts/lib/wizard-pickers.mjs +132 -0
package/README.md
CHANGED
|
@@ -79,7 +79,7 @@ Tip: `tl` works everywhere `ticketlens` does — running `tl`/`ticketlens config
|
|
|
79
79
|
| `ticketlens profiles` | List all configured profiles (alias: `ticketlens ls`) |
|
|
80
80
|
| `ticketlens delete <NAME>` | Remove a profile and its credentials (prompts `y/N` in TTY; use `--yes` in scripts/CI) |
|
|
81
81
|
|
|
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. `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.
|
|
83
83
|
|
|
84
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.
|
|
85
85
|
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.12.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.
|
|
@@ -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
|
}
|
|
@@ -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));
|
|
@@ -10,16 +10,17 @@ import { existsSync, mkdirSync } from 'node:fs';
|
|
|
10
10
|
import { createStyler } from './ansi.mjs';
|
|
11
11
|
import { createSession } from './banner.mjs';
|
|
12
12
|
import { classifyError } from './error-classifier.mjs';
|
|
13
|
-
import { fetchCurrentUser, fetchStatuses } from './jira-client.mjs';
|
|
13
|
+
import { fetchCurrentUser, fetchStatuses, fetchProjects } from './jira-client.mjs';
|
|
14
14
|
import { resolveAdapter } from './resolve-adapter.mjs';
|
|
15
15
|
import { loadProfiles, loadCredentials, saveProfile } from './profile-resolver.mjs';
|
|
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';
|
|
23
|
+
import { pickTicketPrefixes, pickTriageStatuses, DEFAULT_TRIAGE_STATUSES } from './wizard-pickers.mjs';
|
|
23
24
|
|
|
24
25
|
const RETRY_OPTIONS = [
|
|
25
26
|
{ label: 'Retry', sublabel: 'Try again — same credentials (e.g. VPN just connected)', value: 'retry' },
|
|
@@ -103,6 +104,10 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
103
104
|
let email = profile.email || '';
|
|
104
105
|
const existingToken = profileCreds.pat || profileCreds.apiToken || '';
|
|
105
106
|
let token = existingToken;
|
|
107
|
+
// Trust is bound to the exact hostname it was granted for — never a flat
|
|
108
|
+
// per-profile boolean. Editing the URL to a different host must re-trigger
|
|
109
|
+
// the private-IP block and a fresh "Trust this connection?" prompt.
|
|
110
|
+
let trustedHostname = (profile.allowPrivateIp && hostnameOf(profile.baseUrl)) || null;
|
|
106
111
|
|
|
107
112
|
// ── URL ───────────────────────────────────────────────────────────────────
|
|
108
113
|
const urlTyped = await promptText(
|
|
@@ -158,6 +163,24 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
158
163
|
let connected = false;
|
|
159
164
|
let startFrom = 'test';
|
|
160
165
|
|
|
166
|
+
function isTrustedForCurrentUrl() {
|
|
167
|
+
return trustedHostname !== null && hostnameOf(url) === trustedHostname;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function attemptConnection() {
|
|
171
|
+
if (isJira) {
|
|
172
|
+
const testEnv = {
|
|
173
|
+
JIRA_BASE_URL: url,
|
|
174
|
+
JIRA_EMAIL: email,
|
|
175
|
+
JIRA_API_TOKEN: auth !== 'pat' ? token : '',
|
|
176
|
+
JIRA_PAT: auth === 'pat' ? token : '',
|
|
177
|
+
};
|
|
178
|
+
await fetchCurrentUser({ env: testEnv, apiVersion: auth === 'cloud' ? 3 : 2, allowPrivateIp: isTrustedForCurrentUrl() });
|
|
179
|
+
} else {
|
|
180
|
+
await resolveAdapter({ baseUrl: url, auth: trackerType, apiToken: token }).fetchCurrentUser();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
161
184
|
setupLoop: while (true) {
|
|
162
185
|
// Re-prompt URL + auth (on retry with 'url' option)
|
|
163
186
|
if (startFrom === 'url') {
|
|
@@ -216,17 +239,7 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
216
239
|
session.spin('Testing connection...');
|
|
217
240
|
|
|
218
241
|
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
|
-
}
|
|
242
|
+
await attemptConnection();
|
|
230
243
|
session.connected();
|
|
231
244
|
connected = true;
|
|
232
245
|
break setupLoop;
|
|
@@ -234,6 +247,15 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
234
247
|
session.failed();
|
|
235
248
|
const classified = classifyError(err, { baseUrl: url, profileName: target });
|
|
236
249
|
session.footer(classified.message, 'error', classified.hint);
|
|
250
|
+
|
|
251
|
+
if (classified.privateIpBlocked) {
|
|
252
|
+
const trust = await promptYN('Trust this connection?', { stream });
|
|
253
|
+
if (trust) {
|
|
254
|
+
trustedHostname = hostnameOf(url);
|
|
255
|
+
startFrom = 'test';
|
|
256
|
+
continue setupLoop;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
237
259
|
}
|
|
238
260
|
|
|
239
261
|
stream.write(`\n ${s.dim('What would you like to do?')}\n\n`);
|
|
@@ -251,19 +273,39 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
251
273
|
// ── Optional settings ──────────────────────────────────────────────────────
|
|
252
274
|
stream.write(`\n ${s.dim('──── Optional ────')}\n\n`);
|
|
253
275
|
|
|
254
|
-
//
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
276
|
+
// Shared env for post-connection Jira lookups (pickers + fallback validation)
|
|
277
|
+
const jiraEnv = {
|
|
278
|
+
JIRA_BASE_URL: url,
|
|
279
|
+
JIRA_EMAIL: email,
|
|
280
|
+
JIRA_API_TOKEN: auth !== 'pat' ? token : '',
|
|
281
|
+
JIRA_PAT: auth === 'pat' ? token : '',
|
|
282
|
+
};
|
|
283
|
+
const jiraApiVersion = auth === 'cloud' ? 3 : 2;
|
|
284
|
+
|
|
285
|
+
// Ticket prefixes — picked from the instance's project list with current
|
|
286
|
+
// values pre-checked (replace semantics: deselect removes); legacy add-only
|
|
287
|
+
// free text when the picker can't run (non-TTY, fetch failure, Esc, non-Jira)
|
|
288
|
+
let ticketPrefixes = isJira
|
|
289
|
+
? await pickTicketPrefixes({
|
|
290
|
+
fetchProjects: () => fetchProjects({ env: jiraEnv, apiVersion: jiraApiVersion, allowPrivateIp: isTrustedForCurrentUrl() }),
|
|
291
|
+
current: profile.ticketPrefixes || [],
|
|
292
|
+
stream,
|
|
293
|
+
})
|
|
294
|
+
: null;
|
|
295
|
+
if (ticketPrefixes === null) {
|
|
296
|
+
const curPrefixes = (profile.ticketPrefixes || []).join(', ');
|
|
297
|
+
const prefixInput = await promptText(
|
|
298
|
+
s.dim('Ticket prefixes') + s.dim(curPrefixes ? ` [current: ${curPrefixes}]:` : ' (e.g. PROJ,OPS — press Enter to skip):'),
|
|
299
|
+
{ stream }
|
|
300
|
+
);
|
|
301
|
+
const existing = new Set(profile.ticketPrefixes || []);
|
|
302
|
+
if (prefixInput) {
|
|
303
|
+
for (const v of prefixInput.split(',').map(v => v.trim().toUpperCase()).filter(Boolean)) {
|
|
304
|
+
existing.add(v);
|
|
305
|
+
}
|
|
264
306
|
}
|
|
307
|
+
ticketPrefixes = [...existing];
|
|
265
308
|
}
|
|
266
|
-
const ticketPrefixes = [...existing];
|
|
267
309
|
|
|
268
310
|
// Project path
|
|
269
311
|
const home = homedir();
|
|
@@ -297,76 +339,83 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
297
339
|
}
|
|
298
340
|
}
|
|
299
341
|
|
|
300
|
-
// ── Triage statuses
|
|
342
|
+
// ── Triage statuses ────────────────────────────────────────────────────────
|
|
301
343
|
const currentStatuses = profile.triageStatuses?.length
|
|
302
344
|
? profile.triageStatuses
|
|
303
|
-
:
|
|
304
|
-
|
|
345
|
+
: DEFAULT_TRIAGE_STATUSES;
|
|
346
|
+
|
|
347
|
+
// Picked from the instance's status list with current values pre-checked
|
|
348
|
+
// (replace semantics: deselect removes, stale entries kept unless unchecked);
|
|
349
|
+
// legacy add-only merge free text when the picker can't run
|
|
350
|
+
let triageStatuses = isJira
|
|
351
|
+
? await pickTriageStatuses({
|
|
352
|
+
fetchStatuses: () => fetchStatuses({ env: jiraEnv, apiVersion: jiraApiVersion, allowPrivateIp: isTrustedForCurrentUrl() }),
|
|
353
|
+
current: currentStatuses,
|
|
354
|
+
stream,
|
|
355
|
+
})
|
|
356
|
+
: null;
|
|
305
357
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
);
|
|
358
|
+
if (triageStatuses === null) {
|
|
359
|
+
triageStatuses = currentStatuses;
|
|
360
|
+
const curStatusesStr = currentStatuses.join(', ');
|
|
310
361
|
|
|
311
|
-
|
|
362
|
+
const statusInput = await promptText(
|
|
363
|
+
s.dim('Add triage statuses') + s.dim(` [current: ${curStatusesStr} — Enter to keep]:`),
|
|
364
|
+
{ stream }
|
|
365
|
+
);
|
|
312
366
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
367
|
+
if (statusInput) {
|
|
368
|
+
const newEntries = statusInput.split(',').map(v => v.trim()).filter(Boolean);
|
|
369
|
+
const existingLower = new Set(currentStatuses.map(n => n.toLowerCase()));
|
|
370
|
+
const toValidate = newEntries.filter(n => !existingLower.has(n.toLowerCase()));
|
|
317
371
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
JIRA_PAT: auth === 'pat' ? token : '',
|
|
328
|
-
};
|
|
329
|
-
available = await fetchStatuses({ env: validateEnv, apiVersion: auth === 'cloud' ? 3 : 2 });
|
|
330
|
-
} else {
|
|
331
|
-
available = await resolveAdapter({ baseUrl: url, auth: trackerType, apiToken: token }).fetchStatuses();
|
|
332
|
-
}
|
|
372
|
+
if (toValidate.length > 0) {
|
|
373
|
+
stream.write(` ${s.dim('Validating new statuses...')}\n`);
|
|
374
|
+
try {
|
|
375
|
+
let available;
|
|
376
|
+
if (isJira) {
|
|
377
|
+
available = await fetchStatuses({ env: jiraEnv, apiVersion: jiraApiVersion, allowPrivateIp: isTrustedForCurrentUrl() });
|
|
378
|
+
} else {
|
|
379
|
+
available = await resolveAdapter({ baseUrl: url, auth: trackerType, apiToken: token }).fetchStatuses();
|
|
380
|
+
}
|
|
333
381
|
|
|
334
|
-
|
|
335
|
-
|
|
382
|
+
const lowerMap = new Map(available.map(n => [n.toLowerCase(), n]));
|
|
383
|
+
stream.write('\x1b[A\r\x1b[2K');
|
|
336
384
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
} else {
|
|
343
|
-
const exact = lowerMap.get(name.toLowerCase());
|
|
344
|
-
if (exact) {
|
|
345
|
-
stream.write(` ${s.yellow('~')} ${s.dim(name)} → ${s.cyan(exact)}\n`);
|
|
346
|
-
toAdd.push(exact);
|
|
385
|
+
const toAdd = [];
|
|
386
|
+
for (const name of toValidate) {
|
|
387
|
+
if (available.includes(name)) {
|
|
388
|
+
toAdd.push(name);
|
|
389
|
+
stream.write(` ${s.green('✔')} ${name}\n`);
|
|
347
390
|
} else {
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
if (partial) {
|
|
353
|
-
stream.write(` ${s.yellow('~')} ${s.dim(name)} → ${s.cyan(partial)}\n`);
|
|
354
|
-
toAdd.push(partial);
|
|
391
|
+
const exact = lowerMap.get(name.toLowerCase());
|
|
392
|
+
if (exact) {
|
|
393
|
+
stream.write(` ${s.yellow('~')} ${s.dim(name)} → ${s.cyan(exact)}\n`);
|
|
394
|
+
toAdd.push(exact);
|
|
355
395
|
} else {
|
|
356
|
-
|
|
396
|
+
const partial = available.find(a =>
|
|
397
|
+
a.toLowerCase().includes(name.toLowerCase()) ||
|
|
398
|
+
name.toLowerCase().startsWith(a.toLowerCase().split(' ')[0])
|
|
399
|
+
);
|
|
400
|
+
if (partial) {
|
|
401
|
+
stream.write(` ${s.yellow('~')} ${s.dim(name)} → ${s.cyan(partial)}\n`);
|
|
402
|
+
toAdd.push(partial);
|
|
403
|
+
} else {
|
|
404
|
+
stream.write(` ${s.red('✖')} ${name} ${s.dim('(not found — skipped)')}\n`);
|
|
405
|
+
}
|
|
357
406
|
}
|
|
358
407
|
}
|
|
359
408
|
}
|
|
360
|
-
}
|
|
361
409
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
410
|
+
triageStatuses = [...currentStatuses, ...toAdd];
|
|
411
|
+
if (toAdd.length > 0) {
|
|
412
|
+
stream.write(` ${s.dim('Updated list:')} ${triageStatuses.map(n => s.cyan(n)).join(s.dim(', '))}\n`);
|
|
413
|
+
}
|
|
414
|
+
} catch {
|
|
415
|
+
stream.write('\x1b[A\r\x1b[2K');
|
|
416
|
+
// Tracker unreachable — add without validation, deduped
|
|
417
|
+
triageStatuses = [...currentStatuses, ...toValidate.filter(n => !existingLower.has(n.toLowerCase()))];
|
|
365
418
|
}
|
|
366
|
-
} catch {
|
|
367
|
-
stream.write('\x1b[A\r\x1b[2K');
|
|
368
|
-
// Tracker unreachable — add without validation, deduped
|
|
369
|
-
triageStatuses = [...currentStatuses, ...toValidate.filter(n => !existingLower.has(n.toLowerCase()))];
|
|
370
419
|
}
|
|
371
420
|
}
|
|
372
421
|
}
|
|
@@ -397,6 +446,8 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
397
446
|
else delete updated.projectPaths;
|
|
398
447
|
if (cacheTtl && cacheTtl !== DEFAULT_BRIEF_TTL) updated.cacheTtl = cacheTtl;
|
|
399
448
|
else delete updated.cacheTtl;
|
|
449
|
+
if (isTrustedForCurrentUrl()) updated.allowPrivateIp = true;
|
|
450
|
+
else delete updated.allowPrivateIp;
|
|
400
451
|
|
|
401
452
|
const credData = (token !== existingToken)
|
|
402
453
|
? (auth === 'pat' ? { pat: token } : { apiToken: token })
|
|
@@ -68,6 +68,12 @@ export function stripCr(str) {
|
|
|
68
68
|
return str.replace(/\r/g, '');
|
|
69
69
|
}
|
|
70
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
|
+
|
|
71
77
|
/**
|
|
72
78
|
* Build the env-like object expected by jira-client functions.
|
|
73
79
|
* @param {{ baseUrl: string, pat?: string, email?: string, apiToken?: string }} conn
|
|
@@ -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 {
|
|
@@ -9,15 +9,16 @@ import { existsSync, mkdirSync } from 'node:fs';
|
|
|
9
9
|
import { createStyler } from './ansi.mjs';
|
|
10
10
|
import { createSession } from './banner.mjs';
|
|
11
11
|
import { classifyError } from './error-classifier.mjs';
|
|
12
|
-
import { fetchCurrentUser, fetchStatuses } from './jira-client.mjs';
|
|
12
|
+
import { fetchCurrentUser, fetchStatuses, fetchProjects } from './jira-client.mjs';
|
|
13
13
|
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
17
|
import { renderWordmark } from './wordmark.mjs';
|
|
18
18
|
import { printQuickStart } from './quick-start-panel.mjs';
|
|
19
|
-
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
19
|
+
import { DEFAULT_CONFIG_DIR, hostnameOf } from './config.mjs';
|
|
20
20
|
import { SERVER_AUTH_TYPES, promptText, promptSecret, promptYN } from './prompt-helpers.mjs';
|
|
21
|
+
import { pickTicketPrefixes, pickTriageStatuses, DEFAULT_TRIAGE_STATUSES } from './wizard-pickers.mjs';
|
|
21
22
|
|
|
22
23
|
const RETRY_OPTIONS = [
|
|
23
24
|
{ label: 'Retry', sublabel: 'Try again — same credentials (e.g. VPN just connected)', value: 'retry' },
|
|
@@ -328,6 +329,12 @@ async function _run({ configDir, stream, s, showBanner, showQuickStart }) {
|
|
|
328
329
|
let baseUrl = '', authType = '', email = '', token = '';
|
|
329
330
|
let env = {}, apiVersion = 2;
|
|
330
331
|
let startFrom = 'url';
|
|
332
|
+
// Trust is bound to the exact hostname it was granted for — never a flat
|
|
333
|
+
// per-run boolean. Choosing "Edit from URL" after granting trust for one
|
|
334
|
+
// host must re-trigger the private-IP block and a fresh confirmation
|
|
335
|
+
// for the new host, not silently inherit trust.
|
|
336
|
+
let trustedHostname = null;
|
|
337
|
+
const isTrustedForCurrentUrl = () => trustedHostname !== null && hostnameOf(baseUrl) === trustedHostname;
|
|
331
338
|
|
|
332
339
|
setupLoop: while (true) {
|
|
333
340
|
// ── URL + auth type ────────────────────────────────────────────────────
|
|
@@ -432,7 +439,7 @@ async function _run({ configDir, stream, s, showBanner, showQuickStart }) {
|
|
|
432
439
|
session.spin('Testing connection...');
|
|
433
440
|
|
|
434
441
|
try {
|
|
435
|
-
await fetchCurrentUser({ env, apiVersion });
|
|
442
|
+
await fetchCurrentUser({ env, apiVersion, allowPrivateIp: isTrustedForCurrentUrl() });
|
|
436
443
|
session.connected();
|
|
437
444
|
connected = true;
|
|
438
445
|
break setupLoop;
|
|
@@ -440,6 +447,15 @@ async function _run({ configDir, stream, s, showBanner, showQuickStart }) {
|
|
|
440
447
|
session.failed();
|
|
441
448
|
const classified = classifyError(err, { baseUrl, profileName });
|
|
442
449
|
session.footer(classified.message, 'error', classified.hint);
|
|
450
|
+
|
|
451
|
+
if (classified.privateIpBlocked) {
|
|
452
|
+
const trust = await promptYN('Trust this connection?', { stream });
|
|
453
|
+
if (trust) {
|
|
454
|
+
trustedHostname = hostnameOf(baseUrl);
|
|
455
|
+
startFrom = 'retry';
|
|
456
|
+
continue setupLoop;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
443
459
|
}
|
|
444
460
|
|
|
445
461
|
// ── Retry options ──────────────────────────────────────────────────────
|
|
@@ -453,13 +469,20 @@ async function _run({ configDir, stream, s, showBanner, showQuickStart }) {
|
|
|
453
469
|
// ── Optional settings ───────────────────────────────────────────────────
|
|
454
470
|
stream.write(`\n ${s.dim('──── Optional (press Enter to skip) ────')}\n\n`);
|
|
455
471
|
|
|
456
|
-
// Ticket prefixes
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
472
|
+
// Ticket prefixes — picked from the instance's project list; free-text
|
|
473
|
+
// fallback when the picker can't run (non-TTY, fetch failure, Esc)
|
|
474
|
+
let ticketPrefixes = await pickTicketPrefixes({
|
|
475
|
+
fetchProjects: () => fetchProjects({ env, apiVersion, allowPrivateIp: isTrustedForCurrentUrl() }),
|
|
476
|
+
stream,
|
|
477
|
+
});
|
|
478
|
+
if (ticketPrefixes === null) {
|
|
479
|
+
const prefixInput = await promptText(
|
|
480
|
+
s.dim('Ticket prefixes') + s.dim(' (e.g. PROJ,OPS):'), { stream }
|
|
481
|
+
);
|
|
482
|
+
ticketPrefixes = prefixInput
|
|
483
|
+
? prefixInput.split(',').map(v => v.trim().toUpperCase()).filter(Boolean)
|
|
484
|
+
: [];
|
|
485
|
+
}
|
|
463
486
|
|
|
464
487
|
// Project path (single — used for auto-profile detection from cwd)
|
|
465
488
|
const home = homedir();
|
|
@@ -492,45 +515,55 @@ async function _run({ configDir, stream, s, showBanner, showQuickStart }) {
|
|
|
492
515
|
}
|
|
493
516
|
}
|
|
494
517
|
|
|
495
|
-
// Triage statuses —
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
518
|
+
// Triage statuses — picked from the instance's status list with the
|
|
519
|
+
// defaults pre-checked (stale defaults dropped, not user data); free-text
|
|
520
|
+
// + validate fallback when the picker can't run (non-TTY, fetch failure, Esc)
|
|
521
|
+
let triageStatuses = await pickTriageStatuses({
|
|
522
|
+
fetchStatuses: () => fetchStatuses({ env, apiVersion, allowPrivateIp: isTrustedForCurrentUrl() }),
|
|
523
|
+
current: DEFAULT_TRIAGE_STATUSES,
|
|
524
|
+
preserveMissing: false,
|
|
525
|
+
stream,
|
|
526
|
+
});
|
|
527
|
+
if (triageStatuses === null) {
|
|
528
|
+
const DEFAULT_TRIAGE = DEFAULT_TRIAGE_STATUSES.join(', ');
|
|
529
|
+
const statusInput = await promptText(
|
|
530
|
+
s.dim('Triage statuses') + s.dim(` [default: ${DEFAULT_TRIAGE}]:`), { stream }
|
|
531
|
+
);
|
|
532
|
+
triageStatuses = statusInput
|
|
533
|
+
? statusInput.split(',').map(v => v.trim()).filter(Boolean)
|
|
534
|
+
: [...DEFAULT_TRIAGE_STATUSES];
|
|
503
535
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
} else {
|
|
515
|
-
const match = lowerMap.get(name.toLowerCase());
|
|
516
|
-
if (match) {
|
|
517
|
-
stream.write(` ${s.yellow('~')} ${s.dim(name)} → ${s.cyan(match)}\n`);
|
|
518
|
-
corrected.push(match);
|
|
519
|
-
hasIssues = true;
|
|
536
|
+
stream.write(` ${s.dim('Validating statuses...')}\n`);
|
|
537
|
+
try {
|
|
538
|
+
const available = await fetchStatuses({ env, apiVersion, allowPrivateIp: isTrustedForCurrentUrl() });
|
|
539
|
+
const lowerMap = new Map(available.map(n => [n.toLowerCase(), n]));
|
|
540
|
+
stream.write('\x1b[A\r\x1b[2K'); // clear "Validating..." line
|
|
541
|
+
const corrected = [];
|
|
542
|
+
let hasIssues = false;
|
|
543
|
+
for (const name of triageStatuses) {
|
|
544
|
+
if (available.includes(name)) {
|
|
545
|
+
corrected.push(name);
|
|
520
546
|
} else {
|
|
521
|
-
|
|
522
|
-
|
|
547
|
+
const match = lowerMap.get(name.toLowerCase());
|
|
548
|
+
if (match) {
|
|
549
|
+
stream.write(` ${s.yellow('~')} ${s.dim(name)} → ${s.cyan(match)}\n`);
|
|
550
|
+
corrected.push(match);
|
|
551
|
+
hasIssues = true;
|
|
552
|
+
} else {
|
|
553
|
+
stream.write(` ${s.red('✖')} ${name} ${s.dim('(not found in this Jira instance)')}\n`);
|
|
554
|
+
hasIssues = true;
|
|
555
|
+
}
|
|
523
556
|
}
|
|
524
557
|
}
|
|
558
|
+
if (!hasIssues) {
|
|
559
|
+
stream.write(` ${s.green('✔')} Statuses validated\n`);
|
|
560
|
+
} else if (corrected.length > 0) {
|
|
561
|
+
stream.write(` ${s.dim('Using:')} ${corrected.map(n => s.cyan(n)).join(s.dim(', '))}\n`);
|
|
562
|
+
}
|
|
563
|
+
triageStatuses = corrected;
|
|
564
|
+
} catch {
|
|
565
|
+
stream.write('\x1b[A\r\x1b[2K'); // clear silently if Jira call fails
|
|
525
566
|
}
|
|
526
|
-
if (!hasIssues) {
|
|
527
|
-
stream.write(` ${s.green('✔')} Statuses validated\n`);
|
|
528
|
-
} else if (corrected.length > 0) {
|
|
529
|
-
stream.write(` ${s.dim('Using:')} ${corrected.map(n => s.cyan(n)).join(s.dim(', '))}\n`);
|
|
530
|
-
}
|
|
531
|
-
triageStatuses = corrected;
|
|
532
|
-
} catch {
|
|
533
|
-
stream.write('\x1b[A\r\x1b[2K'); // clear silently if Jira call fails
|
|
534
567
|
}
|
|
535
568
|
|
|
536
569
|
// ── Save ──────────────────────────────────────────────────────────────
|
|
@@ -540,6 +573,7 @@ async function _run({ configDir, stream, s, showBanner, showQuickStart }) {
|
|
|
540
573
|
...(email ? { email } : {}),
|
|
541
574
|
...(ticketPrefixes.length > 0 ? { ticketPrefixes } : {}),
|
|
542
575
|
...(projectPaths.length > 0 ? { projectPaths } : {}),
|
|
576
|
+
...(isTrustedForCurrentUrl() ? { allowPrivateIp: true } : {}),
|
|
543
577
|
triageStatuses,
|
|
544
578
|
};
|
|
545
579
|
const credData = authType === 'pat' ? { pat: token } : { apiToken: token };
|
|
@@ -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`);
|
|
@@ -294,9 +318,53 @@ export async function fetchStatuses(opts = {}) {
|
|
|
294
318
|
return [...new Set(raw.map(s => s.name))].sort();
|
|
295
319
|
}
|
|
296
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Lists projects visible to the authenticated user as [{key, name}], key-sorted.
|
|
323
|
+
* v2 (Server/DC): GET /project returns the full array in one response.
|
|
324
|
+
* v3 (Cloud): GET /project/search is paginated — walked until isLast, capped
|
|
325
|
+
* at MAX_PROJECT_PAGES to bound the walk against a misbehaving server.
|
|
326
|
+
*/
|
|
327
|
+
export async function fetchProjects(opts = {}) {
|
|
328
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
|
|
329
|
+
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
330
|
+
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
331
|
+
const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
|
|
332
|
+
|
|
333
|
+
async function getJson(url) {
|
|
334
|
+
const fetchOpts = { headers };
|
|
335
|
+
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
336
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
|
|
337
|
+
if (!response.ok) {
|
|
338
|
+
const err = new Error(`Jira API error ${response.status} fetching projects`);
|
|
339
|
+
err.status = response.status;
|
|
340
|
+
throw err;
|
|
341
|
+
}
|
|
342
|
+
return response.json();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const projects = [];
|
|
346
|
+
if (apiVersion >= 3) {
|
|
347
|
+
const MAX_PROJECT_PAGES = 10;
|
|
348
|
+
const PAGE_SIZE = 50;
|
|
349
|
+
let startAt = 0;
|
|
350
|
+
for (let page = 0; page < MAX_PROJECT_PAGES; page++) {
|
|
351
|
+
const params = new URLSearchParams({ startAt: String(startAt), maxResults: String(PAGE_SIZE) });
|
|
352
|
+
const raw = await getJson(`${baseUrl}/rest/api/3/project/search?${params}`);
|
|
353
|
+
const values = raw.values ?? [];
|
|
354
|
+
for (const p of values) projects.push({ key: p.key, name: p.name ?? '' });
|
|
355
|
+
if (raw.isLast || values.length === 0) break;
|
|
356
|
+
startAt += values.length;
|
|
357
|
+
}
|
|
358
|
+
} else {
|
|
359
|
+
const raw = await getJson(`${baseUrl}/rest/api/2/project`);
|
|
360
|
+
for (const p of raw) projects.push({ key: p.key, name: p.name ?? '' });
|
|
361
|
+
}
|
|
362
|
+
return projects.sort((a, b) => a.key.localeCompare(b.key));
|
|
363
|
+
}
|
|
364
|
+
|
|
297
365
|
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);
|
|
366
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), maxResults = 50, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false, allowPrivateIp = false } = opts;
|
|
367
|
+
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
300
368
|
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
301
369
|
const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
|
|
302
370
|
|
|
@@ -307,7 +375,7 @@ export async function searchTickets(jql, opts = {}) {
|
|
|
307
375
|
const url = `${baseUrl}${endpoint}?${params}`;
|
|
308
376
|
const fetchOpts = { headers };
|
|
309
377
|
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
310
|
-
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
|
|
378
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
|
|
311
379
|
|
|
312
380
|
if (!response.ok) {
|
|
313
381
|
let detail = '';
|
|
@@ -323,15 +391,15 @@ export async function searchTickets(jql, opts = {}) {
|
|
|
323
391
|
}
|
|
324
392
|
|
|
325
393
|
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);
|
|
394
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
|
|
395
|
+
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
328
396
|
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
329
397
|
const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}/remotelink`;
|
|
330
398
|
|
|
331
399
|
const fetchOpts = { headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' } };
|
|
332
400
|
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
333
401
|
|
|
334
|
-
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
|
|
402
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
|
|
335
403
|
if (!response.ok) return [];
|
|
336
404
|
|
|
337
405
|
const raw = await response.json();
|
|
@@ -341,8 +409,8 @@ export async function fetchRemoteLinks(ticketKey, opts = {}) {
|
|
|
341
409
|
}
|
|
342
410
|
|
|
343
411
|
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);
|
|
412
|
+
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;
|
|
413
|
+
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
346
414
|
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
347
415
|
const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
|
|
348
416
|
|
|
@@ -350,7 +418,7 @@ export async function fetchTicket(ticketKey, opts = {}) {
|
|
|
350
418
|
const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}${expand}`;
|
|
351
419
|
const fetchOpts = { headers };
|
|
352
420
|
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
353
|
-
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
|
|
421
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
|
|
354
422
|
|
|
355
423
|
if (!response.ok) {
|
|
356
424
|
const err = new Error(`Jira API error ${response.status} fetching ${ticketKey}`);
|
|
@@ -233,6 +233,7 @@ export function resolveConnection(ticketKey, opts = {}) {
|
|
|
233
233
|
ticketPrefixes: profile.ticketPrefixes || null,
|
|
234
234
|
attentionRules: profile.attentionRules ?? null,
|
|
235
235
|
staleRule: profile.staleRule ?? null,
|
|
236
|
+
allowPrivateIp: profile.allowPrivateIp || false,
|
|
236
237
|
source: 'profile',
|
|
237
238
|
profileName: profile.name,
|
|
238
239
|
};
|
|
@@ -118,6 +118,7 @@ export async function runSwitch({ configDir = DEFAULT_CONFIG_DIR, stream = proce
|
|
|
118
118
|
JIRA_PAT: profileCreds.pat || '',
|
|
119
119
|
};
|
|
120
120
|
const apiVersion = profile.auth === 'cloud' ? 3 : 2;
|
|
121
|
+
const allowPrivateIp = profile.allowPrivateIp;
|
|
121
122
|
|
|
122
123
|
let frame = 0;
|
|
123
124
|
const spinLine = () => ` ${s.cyan(SPINNER_FRAMES[frame])} Connecting to ${s.bold(chosen.name)}...`;
|
|
@@ -129,7 +130,7 @@ export async function runSwitch({ configDir = DEFAULT_CONFIG_DIR, stream = proce
|
|
|
129
130
|
}, 80);
|
|
130
131
|
|
|
131
132
|
try {
|
|
132
|
-
await fetchCurrentUser({ env, apiVersion });
|
|
133
|
+
await fetchCurrentUser({ env, apiVersion, allowPrivateIp });
|
|
133
134
|
clearInterval(timer);
|
|
134
135
|
stream.write('\r\x1b[2K\x1b[A\r\x1b[2K');
|
|
135
136
|
await saveDefault(chosen.name, configDir);
|
|
@@ -30,9 +30,8 @@ export const visLen = (str) => str.replace(ANSI_RE, '').length;
|
|
|
30
30
|
* then drain. Call this — and await it — before starting any new raw-mode
|
|
31
31
|
* prompt.
|
|
32
32
|
*/
|
|
33
|
-
export function flushStdin() {
|
|
33
|
+
export function flushStdin(stdin = process.stdin) {
|
|
34
34
|
return new Promise((resolve) => {
|
|
35
|
-
const stdin = process.stdin;
|
|
36
35
|
if (typeof stdin.read !== 'function' || typeof stdin.resume !== 'function') {
|
|
37
36
|
resolve();
|
|
38
37
|
return;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Reusable raw-mode selector
|
|
3
|
-
* Used by profile-picker.mjs, profile-switcher.mjs,
|
|
2
|
+
* Reusable raw-mode selector primitives: single-select and multi-select.
|
|
3
|
+
* Used by profile-picker.mjs, profile-switcher.mjs, init-wizard.mjs,
|
|
4
|
+
* config-wizard.mjs, and wizard-pickers.mjs.
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import { createStyler } from './ansi.mjs';
|
|
@@ -16,10 +17,15 @@ import { flushStdin } from './prompt-helpers.mjs';
|
|
|
16
17
|
* @param {number} [opts.initialIndex=0]
|
|
17
18
|
* @param {(index: number) => number} opts.renderFn
|
|
18
19
|
* @param {NodeJS.WriteStream} [opts.stream=process.stderr]
|
|
20
|
+
* @param {NodeJS.ReadStream} [opts.stdin=process.stdin] injectable for tests
|
|
21
|
+
* @param {(key: string, index: number) => 'veto'|boolean} [opts.onKey]
|
|
22
|
+
* Hook for extra keys. Called for Enter before submit — return 'veto' to
|
|
23
|
+
* swallow it and keep the selector open. For any other unhandled key,
|
|
24
|
+
* return truthy to re-render. Falsy = key ignored.
|
|
19
25
|
* @returns {Promise<number|null>} selected index, or null if cancelled
|
|
20
26
|
*/
|
|
21
|
-
export function runRawSelect({ count, initialIndex = 0, renderFn, stream = process.stderr }) {
|
|
22
|
-
if (!stream.isTTY || !
|
|
27
|
+
export function runRawSelect({ count, initialIndex = 0, renderFn, stream = process.stderr, stdin = process.stdin, onKey }) {
|
|
28
|
+
if (!stream.isTTY || !stdin.setRawMode) return Promise.resolve(null);
|
|
23
29
|
|
|
24
30
|
let selected = initialIndex;
|
|
25
31
|
let lineCount = 0;
|
|
@@ -34,8 +40,7 @@ export function runRawSelect({ count, initialIndex = 0, renderFn, stream = proce
|
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
stream.write('\x1b[?25l');
|
|
37
|
-
return flushStdin().then(() => new Promise((resolve) => {
|
|
38
|
-
const stdin = process.stdin;
|
|
43
|
+
return flushStdin(stdin).then(() => new Promise((resolve) => {
|
|
39
44
|
const wasRaw = stdin.isRaw;
|
|
40
45
|
|
|
41
46
|
function cleanup() {
|
|
@@ -52,7 +57,11 @@ export function runRawSelect({ count, initialIndex = 0, renderFn, stream = proce
|
|
|
52
57
|
}
|
|
53
58
|
if (key === '\x1b[A' && selected > 0) { selected--; render(); return; }
|
|
54
59
|
if (key === '\x1b[B' && selected < count - 1) { selected++; render(); return; }
|
|
55
|
-
if (key === '\r' || key === '\n') {
|
|
60
|
+
if (key === '\r' || key === '\n') {
|
|
61
|
+
if (onKey && onKey(key, selected) === 'veto') { render(); return; }
|
|
62
|
+
cleanup(); erase(); resolve(selected); return;
|
|
63
|
+
}
|
|
64
|
+
if (onKey && onKey(key, selected)) { render(); return; }
|
|
56
65
|
}
|
|
57
66
|
|
|
58
67
|
stdin.setRawMode(true);
|
|
@@ -105,3 +114,88 @@ export function promptSelect(items, opts = {}) {
|
|
|
105
114
|
|
|
106
115
|
return runRawSelect({ count: items.length, initialIndex, renderFn, stream });
|
|
107
116
|
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Arrow-key multi-select with checkboxes and a scrolling viewport.
|
|
120
|
+
* Space toggles the highlighted row, `a` toggles all, Enter confirms,
|
|
121
|
+
* Esc cancels (returns null — callers treat that as "enter manually").
|
|
122
|
+
*
|
|
123
|
+
* @param {Array<{label: string}>} items
|
|
124
|
+
* @param {object} [opts]
|
|
125
|
+
* @param {number[]} [opts.initialSelected=[]] pre-checked item indices
|
|
126
|
+
* @param {number} [opts.minSelected=0] Enter is vetoed below this count
|
|
127
|
+
* @param {number} [opts.maxVisible=10] viewport height for long lists
|
|
128
|
+
* @param {string} [opts.hint]
|
|
129
|
+
* @param {NodeJS.WriteStream} [opts.stream]
|
|
130
|
+
* @param {NodeJS.ReadStream} [opts.stdin] injectable for tests
|
|
131
|
+
* @returns {Promise<number[]|null>} checked indices in list order, or null if cancelled
|
|
132
|
+
*/
|
|
133
|
+
export function promptMultiSelect(items, opts = {}) {
|
|
134
|
+
const {
|
|
135
|
+
stream = process.stderr,
|
|
136
|
+
stdin = process.stdin,
|
|
137
|
+
hint = '↑/↓ move Space toggle a all Enter confirm Esc manual entry',
|
|
138
|
+
initialSelected = [],
|
|
139
|
+
minSelected = 0,
|
|
140
|
+
maxVisible = 10,
|
|
141
|
+
} = opts;
|
|
142
|
+
|
|
143
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
144
|
+
|
|
145
|
+
if (!stream.isTTY || !stdin.setRawMode) {
|
|
146
|
+
for (const item of items) stream.write(` ${s.cyan('›')} ${item.label}\n`);
|
|
147
|
+
return Promise.resolve(null);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const checked = new Set(initialSelected.filter(i => i >= 0 && i < items.length));
|
|
151
|
+
let scrollTop = 0;
|
|
152
|
+
let flash = '';
|
|
153
|
+
|
|
154
|
+
function renderFn(selected) {
|
|
155
|
+
if (selected >= scrollTop + maxVisible) scrollTop = selected - maxVisible + 1;
|
|
156
|
+
if (selected < scrollTop) scrollTop = selected;
|
|
157
|
+
const visibleEnd = Math.min(scrollTop + maxVisible, items.length);
|
|
158
|
+
|
|
159
|
+
const lines = [];
|
|
160
|
+
for (let i = scrollTop; i < visibleEnd; i++) {
|
|
161
|
+
const isSelected = i === selected;
|
|
162
|
+
const marker = isSelected ? s.blue('❯') : ' ';
|
|
163
|
+
const box = checked.has(i) ? s.green('◉') : s.dim('○');
|
|
164
|
+
const label = isSelected ? s.bold(s.blue(items[i].label)) : items[i].label;
|
|
165
|
+
lines.push(` ${marker} ${box} ${label}`);
|
|
166
|
+
}
|
|
167
|
+
if (items.length > maxVisible) {
|
|
168
|
+
lines.push(` ${s.dim(`${scrollTop + 1}-${visibleEnd} of ${items.length}`)}`);
|
|
169
|
+
}
|
|
170
|
+
lines.push('');
|
|
171
|
+
lines.push(` ${s.dim(hint)}${flash ? ' ' + s.yellow(flash) : ''}`);
|
|
172
|
+
stream.write(lines.join('\n') + '\n');
|
|
173
|
+
return lines.length;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function onKey(key, selected) {
|
|
177
|
+
if (key === ' ') {
|
|
178
|
+
flash = '';
|
|
179
|
+
if (checked.has(selected)) checked.delete(selected);
|
|
180
|
+
else checked.add(selected);
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
if (key === 'a' || key === 'A') {
|
|
184
|
+
flash = '';
|
|
185
|
+
if (checked.size === items.length) checked.clear();
|
|
186
|
+
else for (let i = 0; i < items.length; i++) checked.add(i);
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
if (key === '\r' || key === '\n') {
|
|
190
|
+
if (checked.size < minSelected) {
|
|
191
|
+
flash = `select at least ${minSelected}`;
|
|
192
|
+
return 'veto';
|
|
193
|
+
}
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return runRawSelect({ count: items.length, renderFn, stream, stdin, onKey })
|
|
200
|
+
.then(result => (result === null ? null : [...checked].sort((a, b) => a - b)));
|
|
201
|
+
}
|
|
@@ -9,7 +9,7 @@ import { join } from 'node:path';
|
|
|
9
9
|
import { readCliToken } from './cli-auth.mjs';
|
|
10
10
|
import { saveProfile, loadProfiles } from './profile-resolver.mjs';
|
|
11
11
|
import { apiBase } from './api-utils.mjs';
|
|
12
|
-
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
12
|
+
import { DEFAULT_CONFIG_DIR, hostnameOf } from './config.mjs';
|
|
13
13
|
|
|
14
14
|
const META_FILE = 'team-jira-meta.json';
|
|
15
15
|
|
|
@@ -78,11 +78,23 @@ function teamConfigToCliProfile(apiData) {
|
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
80
|
* Writes team config to the named profile without touching credentials.
|
|
81
|
+
* Carries forward `allowPrivateIp` from the existing LOCAL profile only —
|
|
82
|
+
* never from server data (see teamConfigToCliProfile) — and only when the
|
|
83
|
+
* synced baseUrl resolves to the SAME hostname the trust was granted for.
|
|
84
|
+
* A sync that changes the host (legitimate re-point, or a compromised/
|
|
85
|
+
* malicious sync response) must never inherit trust for a different,
|
|
86
|
+
* unconfirmed host — the member has to re-confirm via the wizard.
|
|
81
87
|
* Returns { error, message } on failure — never throws.
|
|
82
88
|
*/
|
|
83
89
|
async function applyTeamJiraConfig(groupName, profileData, configDir) {
|
|
84
90
|
try {
|
|
85
|
-
|
|
91
|
+
const existing = loadProfiles(configDir)?.profiles?.[groupName];
|
|
92
|
+
const sameHost = existing?.baseUrl && profileData.baseUrl &&
|
|
93
|
+
hostnameOf(existing.baseUrl) === hostnameOf(profileData.baseUrl);
|
|
94
|
+
const merged = (existing?.allowPrivateIp && sameHost)
|
|
95
|
+
? { ...profileData, allowPrivateIp: true }
|
|
96
|
+
: profileData;
|
|
97
|
+
await saveProfile(groupName, merged, null, configDir);
|
|
86
98
|
return { ok: true };
|
|
87
99
|
} catch (e) {
|
|
88
100
|
return { error: 'save-failed', message: e.message };
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared prefix/status picker steps for init-wizard.mjs and config-wizard.mjs.
|
|
3
|
+
* Live-fetched multi-select over the connected Jira instance's projects and
|
|
4
|
+
* statuses. Every picker returns string[] on confirm, or null when the picker
|
|
5
|
+
* cannot run (non-TTY, fetch failure, empty server list) or the user hits Esc
|
|
6
|
+
* — callers treat null as "use the legacy free-text prompt".
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createStyler } from './ansi.mjs';
|
|
10
|
+
import { promptMultiSelect } from './select-prompt.mjs';
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_TRIAGE_STATUSES = ['In Progress', 'Code Review', 'QA'];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Builds picker rows from server values plus the user's current selections.
|
|
16
|
+
* Matching is case-insensitive; rows echo the server's canonical casing so a
|
|
17
|
+
* confirm fixes case drift in the profile. Current values missing from the
|
|
18
|
+
* server are appended as marked, pre-checked rows when preserveMissing is on
|
|
19
|
+
* (edit flows — user data is never silently dropped) and skipped when off
|
|
20
|
+
* (init flows — stale defaults are not user data).
|
|
21
|
+
*/
|
|
22
|
+
function buildRows({ serverValues, labels, current, preserveMissing, s }) {
|
|
23
|
+
const values = [...serverValues];
|
|
24
|
+
const rows = serverValues.map((v, i) => ({ label: labels[i] }));
|
|
25
|
+
const serverLower = new Set(serverValues.map(v => v.toLowerCase()));
|
|
26
|
+
|
|
27
|
+
const staleSeen = new Set(); // legacy free-text merge never deduped within itself
|
|
28
|
+
for (const cur of current) {
|
|
29
|
+
const lower = cur.toLowerCase();
|
|
30
|
+
if (preserveMissing && !serverLower.has(lower) && !staleSeen.has(lower)) {
|
|
31
|
+
staleSeen.add(lower);
|
|
32
|
+
values.push(cur);
|
|
33
|
+
rows.push({ label: `${cur} ${s.dim('(not on server)')}` });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const currentLower = new Set(current.map(c => c.toLowerCase()));
|
|
38
|
+
const initialSelected = values
|
|
39
|
+
.map((v, i) => (currentLower.has(v.toLowerCase()) ? i : -1))
|
|
40
|
+
.filter(i => i >= 0);
|
|
41
|
+
|
|
42
|
+
return { values, rows, initialSelected };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Fetches via fetchFn behind a clearable progress line. Returns the list, or
|
|
47
|
+
* null after printing a fallback warning (raw error details stay internal —
|
|
48
|
+
* the connection already tested fine, so this is a transient/permission issue).
|
|
49
|
+
*/
|
|
50
|
+
async function fetchForPicker(fetchFn, { what, stream, s }) {
|
|
51
|
+
stream.write(` ${s.dim(`Fetching ${what}...`)}\n`);
|
|
52
|
+
try {
|
|
53
|
+
const list = await fetchFn();
|
|
54
|
+
stream.write('\x1b[A\r\x1b[2K');
|
|
55
|
+
return list;
|
|
56
|
+
} catch {
|
|
57
|
+
stream.write('\x1b[A\r\x1b[2K');
|
|
58
|
+
stream.write(` ${s.yellow('~')} Could not fetch ${what} — enter manually\n`);
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function writeConfirmation(chosen, { stream, s }) {
|
|
64
|
+
stream.write(` ${s.green('✔')} ${chosen.length ? chosen.map(v => s.cyan(v)).join(s.dim(', ')) : s.dim('none')}\n`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Multi-select over the instance's projects; returns the chosen project keys.
|
|
69
|
+
*
|
|
70
|
+
* @param {object} opts
|
|
71
|
+
* @param {() => Promise<Array<{key: string, name: string}>>} opts.fetchProjects
|
|
72
|
+
* pre-bound by the caller — env/apiVersion/allowPrivateIp trust stays there
|
|
73
|
+
* @param {string[]} [opts.current=[]] pre-checked prefixes (edit flow)
|
|
74
|
+
* @param {boolean} [opts.preserveMissing=true]
|
|
75
|
+
* @returns {Promise<string[]|null>} chosen keys, or null → free-text fallback
|
|
76
|
+
*/
|
|
77
|
+
export async function pickTicketPrefixes({ fetchProjects, current = [], preserveMissing = true, stream = process.stderr, stdin = process.stdin } = {}) {
|
|
78
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
79
|
+
if (!stream.isTTY || !stdin.setRawMode) return null;
|
|
80
|
+
|
|
81
|
+
const projects = await fetchForPicker(fetchProjects, { what: 'projects', stream, s });
|
|
82
|
+
if (projects === null || projects.length === 0) return null;
|
|
83
|
+
|
|
84
|
+
const { values, rows, initialSelected } = buildRows({
|
|
85
|
+
serverValues: projects.map(p => p.key),
|
|
86
|
+
labels: projects.map(p => (p.name ? `${p.key} ${s.dim('—')} ${p.name}` : p.key)),
|
|
87
|
+
current,
|
|
88
|
+
preserveMissing,
|
|
89
|
+
s,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
stream.write(` ${s.dim('Ticket prefixes')} ${s.dim('(projects on this instance)')}\n\n`);
|
|
93
|
+
const picked = await promptMultiSelect(rows, { initialSelected, stream, stdin });
|
|
94
|
+
if (picked === null) return null;
|
|
95
|
+
|
|
96
|
+
const chosen = picked.map(i => values[i]);
|
|
97
|
+
writeConfirmation(chosen, { stream, s });
|
|
98
|
+
return chosen;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Multi-select over the instance's status names; at least one required.
|
|
103
|
+
*
|
|
104
|
+
* @param {object} opts
|
|
105
|
+
* @param {() => Promise<string[]>} opts.fetchStatuses pre-bound by the caller
|
|
106
|
+
* @param {string[]} [opts.current=[]] pre-checked statuses
|
|
107
|
+
* @param {boolean} [opts.preserveMissing=true]
|
|
108
|
+
* @returns {Promise<string[]|null>} chosen names, or null → free-text fallback
|
|
109
|
+
*/
|
|
110
|
+
export async function pickTriageStatuses({ fetchStatuses, current = [], preserveMissing = true, stream = process.stderr, stdin = process.stdin } = {}) {
|
|
111
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
112
|
+
if (!stream.isTTY || !stdin.setRawMode) return null;
|
|
113
|
+
|
|
114
|
+
const available = await fetchForPicker(fetchStatuses, { what: 'statuses', stream, s });
|
|
115
|
+
if (available === null || available.length === 0) return null;
|
|
116
|
+
|
|
117
|
+
const { values, rows, initialSelected } = buildRows({
|
|
118
|
+
serverValues: available,
|
|
119
|
+
labels: available,
|
|
120
|
+
current,
|
|
121
|
+
preserveMissing,
|
|
122
|
+
s,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
stream.write(` ${s.dim('Triage statuses')} ${s.dim('(statuses on this instance)')}\n\n`);
|
|
126
|
+
const picked = await promptMultiSelect(rows, { initialSelected, minSelected: 1, stream, stdin });
|
|
127
|
+
if (picked === null) return null;
|
|
128
|
+
|
|
129
|
+
const chosen = picked.map(i => values[i]);
|
|
130
|
+
writeConfirmation(chosen, { stream, s });
|
|
131
|
+
return chosen;
|
|
132
|
+
}
|