ticketlens 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +457 -0
  3. package/bin/ticketlens.mjs +376 -0
  4. package/package.json +37 -0
  5. package/skills/jtb/scripts/fetch-my-tickets.mjs +377 -0
  6. package/skills/jtb/scripts/fetch-ticket.mjs +682 -0
  7. package/skills/jtb/scripts/lib/adapters/github-adapter.mjs +112 -0
  8. package/skills/jtb/scripts/lib/adapters/jira-adapter.mjs +19 -0
  9. package/skills/jtb/scripts/lib/adf-converter.mjs +67 -0
  10. package/skills/jtb/scripts/lib/ansi.mjs +87 -0
  11. package/skills/jtb/scripts/lib/arg-validator.mjs +178 -0
  12. package/skills/jtb/scripts/lib/attachment-downloader.mjs +123 -0
  13. package/skills/jtb/scripts/lib/attention-scorer.mjs +152 -0
  14. package/skills/jtb/scripts/lib/banner.mjs +201 -0
  15. package/skills/jtb/scripts/lib/brief-assembler.mjs +137 -0
  16. package/skills/jtb/scripts/lib/brief-cache.mjs +137 -0
  17. package/skills/jtb/scripts/lib/budget-pruner.mjs +242 -0
  18. package/skills/jtb/scripts/lib/cache-manager.mjs +499 -0
  19. package/skills/jtb/scripts/lib/cli-auth.mjs +40 -0
  20. package/skills/jtb/scripts/lib/cli.mjs +87 -0
  21. package/skills/jtb/scripts/lib/code-ref-parser.mjs +113 -0
  22. package/skills/jtb/scripts/lib/commit-linker.mjs +42 -0
  23. package/skills/jtb/scripts/lib/compliance-checker.mjs +92 -0
  24. package/skills/jtb/scripts/lib/config-wizard.mjs +392 -0
  25. package/skills/jtb/scripts/lib/config.mjs +63 -0
  26. package/skills/jtb/scripts/lib/diff-analyzer.mjs +66 -0
  27. package/skills/jtb/scripts/lib/drift-tracker.mjs +120 -0
  28. package/skills/jtb/scripts/lib/error-classifier.mjs +119 -0
  29. package/skills/jtb/scripts/lib/help.mjs +253 -0
  30. package/skills/jtb/scripts/lib/hook-installer.mjs +81 -0
  31. package/skills/jtb/scripts/lib/init-wizard.mjs +508 -0
  32. package/skills/jtb/scripts/lib/interactive-list.mjs +257 -0
  33. package/skills/jtb/scripts/lib/jira-client.mjs +169 -0
  34. package/skills/jtb/scripts/lib/ledger.mjs +96 -0
  35. package/skills/jtb/scripts/lib/license.mjs +195 -0
  36. package/skills/jtb/scripts/lib/pr-assembler.mjs +186 -0
  37. package/skills/jtb/scripts/lib/profile-picker.mjs +216 -0
  38. package/skills/jtb/scripts/lib/profile-resolver.mjs +236 -0
  39. package/skills/jtb/scripts/lib/profile-switcher.mjs +147 -0
  40. package/skills/jtb/scripts/lib/prompt-helpers.mjs +122 -0
  41. package/skills/jtb/scripts/lib/requirement-extractor.mjs +52 -0
  42. package/skills/jtb/scripts/lib/resolve-adapter.mjs +28 -0
  43. package/skills/jtb/scripts/lib/schedule-wizard.mjs +153 -0
  44. package/skills/jtb/scripts/lib/select-prompt.mjs +106 -0
  45. package/skills/jtb/scripts/lib/spinner.mjs +44 -0
  46. package/skills/jtb/scripts/lib/styled-assembler.mjs +183 -0
  47. package/skills/jtb/scripts/lib/summarizer.mjs +109 -0
  48. package/skills/jtb/scripts/lib/sync.mjs +119 -0
  49. package/skills/jtb/scripts/lib/table-formatter.mjs +48 -0
  50. package/skills/jtb/scripts/lib/triage-exporter.mjs +93 -0
  51. package/skills/jtb/scripts/lib/triage-history.mjs +166 -0
  52. package/skills/jtb/scripts/lib/triage-push.mjs +98 -0
  53. package/skills/jtb/scripts/lib/usage-tracker.mjs +54 -0
  54. package/skills/jtb/scripts/lib/vcs-detector.mjs +12 -0
@@ -0,0 +1,195 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import crypto from 'node:crypto';
5
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
6
+ import { createStyler } from './ansi.mjs';
7
+
8
+ export const LICENSE_TIERS = { free: 0, pro: 1, team: 2 };
9
+ const LICENSE_FILE = 'license.json';
10
+ const REVALIDATION_DAYS = 7; // attempt background revalidation after this many days
11
+ const GRACE_DAYS = 30; // treat license as invalid if not revalidated within this window
12
+ const UPGRADE_URL = 'https://ticketlens.dev/pricing';
13
+
14
+ const ANSI_RE_LIC = /\x1b\[[0-9;]*m|\x1b\]8;[^\x07]*\x07/g;
15
+ const visLen = (s) => s.replace(ANSI_RE_LIC, '').length;
16
+ function padInner(str, width) {
17
+ return str + ' '.repeat(Math.max(0, width - visLen(str)));
18
+ }
19
+
20
+ export function readLicense(configDir = DEFAULT_CONFIG_DIR) {
21
+ const filePath = path.join(configDir, LICENSE_FILE);
22
+ try {
23
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
24
+ const { sig, ...payload } = data;
25
+ // Legacy unsigned files are trusted; will be re-signed on next write
26
+ if (!sig) return payload;
27
+ const expected = crypto.createHmac('sha256', payload.key || '')
28
+ .update(JSON.stringify(payload)).digest('hex');
29
+ return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)) ? payload : null;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ export function writeLicense(data, configDir = DEFAULT_CONFIG_DIR) {
36
+ fs.mkdirSync(configDir, { recursive: true });
37
+ const filePath = path.join(configDir, LICENSE_FILE);
38
+ const { sig: _, ...payload } = data; // strip any existing sig before re-signing
39
+ const mac = crypto.createHmac('sha256', payload.key || '')
40
+ .update(JSON.stringify(payload)).digest('hex');
41
+ fs.writeFileSync(filePath, JSON.stringify({ ...payload, sig: mac }), 'utf8');
42
+ fs.chmodSync(filePath, 0o600);
43
+ }
44
+
45
+ export function isLicensed(tier, configDir = DEFAULT_CONFIG_DIR) {
46
+ const required = LICENSE_TIERS[tier] ?? 0;
47
+ if (required === 0) return true;
48
+
49
+ const license = readLicense(configDir);
50
+ if (!license) return false;
51
+
52
+ // Hard expiry — subscription cancelled, confirmed by server
53
+ if (license.expiresAt && new Date(license.expiresAt) < new Date()) return false;
54
+
55
+ // Offline grace period — reject if not revalidated within GRACE_DAYS
56
+ if (license.validatedAt) {
57
+ const daysSince = (Date.now() - new Date(license.validatedAt)) / 86400000;
58
+ if (daysSince > GRACE_DAYS) return false;
59
+ }
60
+
61
+ const actual = LICENSE_TIERS[license.tier] ?? 0;
62
+ return actual >= required;
63
+ }
64
+
65
+ /**
66
+ * Fire-and-forget background revalidation. Call at CLI startup; never awaited.
67
+ * Silently updates license.json when the validation window has elapsed.
68
+ */
69
+ export function revalidateIfStale(opts = {}) {
70
+ const { configDir = DEFAULT_CONFIG_DIR, fetcher = globalThis.fetch } = opts;
71
+ const license = readLicense(configDir);
72
+ if (!license?.key || !license?.validatedAt) return;
73
+ const daysSince = (Date.now() - new Date(license.validatedAt)) / 86400000;
74
+ if (daysSince < REVALIDATION_DAYS) return;
75
+ revalidateLicense({ configDir, fetcher }).catch(() => {});
76
+ }
77
+
78
+ /**
79
+ * Styled upsell prompt — written to stderr, never pollutes stdout.
80
+ */
81
+ export function showUpgradePrompt(requiredTier, featureFlag, { stream = process.stderr } = {}) {
82
+ const s = createStyler({ isTTY: stream.isTTY });
83
+ const tier = requiredTier.charAt(0).toUpperCase() + requiredTier.slice(1);
84
+ const W = 44; // inner width
85
+ const bc = (t) => s.dim(t);
86
+
87
+ const featureLine = padInner(` ${s.yellow('◆')} ${s.bold(featureFlag)} requires ${s.bold(s.cyan(tier))}`, W);
88
+ const upgradeLine = padInner(` ${s.dim('Upgrade:')} ${s.link(UPGRADE_URL, s.cyan(UPGRADE_URL))}`, W);
89
+ const activateLine = padInner(` ${s.dim('Or run:')} ${s.dim('ticketlens activate <KEY>')}`, W);
90
+ const blank = ' '.repeat(W);
91
+
92
+ stream.write('\n');
93
+ stream.write(` ${bc('┌' + '─'.repeat(W) + '┐')}\n`);
94
+ stream.write(` ${bc('│')}${featureLine}${bc('│')}\n`);
95
+ stream.write(` ${bc('│')}${blank}${bc('│')}\n`);
96
+ stream.write(` ${bc('│')}${upgradeLine}${bc('│')}\n`);
97
+ stream.write(` ${bc('│')}${activateLine}${bc('│')}\n`);
98
+ stream.write(` ${bc('└' + '─'.repeat(W) + '┘')}\n`);
99
+ stream.write('\n');
100
+ }
101
+
102
+ const LEMONSQUEEZY_ACTIVATE_URL = 'https://api.lemonsqueezy.com/v1/licenses/activate';
103
+ const LEMONSQUEEZY_VALIDATE_URL = 'https://api.lemonsqueezy.com/v1/licenses/validate';
104
+
105
+ function extractTier(meta) {
106
+ if (!meta) return 'pro';
107
+ const name = (meta.variant_name || meta.product_name || '').toLowerCase();
108
+ if (name.includes('team')) return 'team';
109
+ return 'pro';
110
+ }
111
+
112
+ export async function activateLicense(key, opts = {}) {
113
+ const { configDir = DEFAULT_CONFIG_DIR, fetcher = globalThis.fetch, instanceName } = opts;
114
+ const instance = instanceName || os.hostname();
115
+
116
+ try {
117
+ const res = await fetcher(LEMONSQUEEZY_ACTIVATE_URL, {
118
+ method: 'POST',
119
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
120
+ body: JSON.stringify({ license_key: key, instance_name: instance }),
121
+ });
122
+
123
+ const data = await res.json();
124
+
125
+ if (!data.activated && !data.valid) {
126
+ return { success: false, error: data.error || data.message || 'Invalid license key.' };
127
+ }
128
+
129
+ const meta = data.meta || {};
130
+ const tier = extractTier(meta);
131
+
132
+ const license = {
133
+ key: data.license_key?.key || key,
134
+ tier,
135
+ email: meta.customer_email || null,
136
+ provider: 'lemonsqueezy',
137
+ instanceId: data.instance?.id || null,
138
+ validatedAt: new Date().toISOString(),
139
+ ...(meta.ends_at ? { expiresAt: meta.ends_at } : {}),
140
+ };
141
+
142
+ writeLicense(license, configDir);
143
+ return { success: true, tier, email: license.email };
144
+ } catch (err) {
145
+ return { success: false, error: err.message };
146
+ }
147
+ }
148
+
149
+ export async function revalidateLicense(opts = {}) {
150
+ const { configDir = DEFAULT_CONFIG_DIR, fetcher = globalThis.fetch, instanceName } = opts;
151
+ const license = readLicense(configDir);
152
+ if (!license) return { success: false, error: 'No license found.' };
153
+
154
+ const instance = instanceName || os.hostname();
155
+
156
+ try {
157
+ const res = await fetcher(LEMONSQUEEZY_VALIDATE_URL, {
158
+ method: 'POST',
159
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
160
+ body: JSON.stringify({ license_key: license.key, instance_name: instance }),
161
+ });
162
+
163
+ const data = await res.json();
164
+
165
+ if (!data.valid) {
166
+ license.expiresAt = new Date().toISOString();
167
+ writeLicense(license, configDir);
168
+ return { success: false, error: 'License is no longer valid.' };
169
+ }
170
+
171
+ license.validatedAt = new Date().toISOString();
172
+ if (data.meta?.ends_at) license.expiresAt = data.meta.ends_at;
173
+ else delete license.expiresAt;
174
+ writeLicense(license, configDir);
175
+ return { success: true, tier: license.tier };
176
+ } catch {
177
+ return { success: true, tier: license.tier, cached: true };
178
+ }
179
+ }
180
+
181
+ export function checkLicense(configDir = DEFAULT_CONFIG_DIR) {
182
+ const license = readLicense(configDir);
183
+ if (!license) return { tier: 'free', active: false };
184
+
185
+ const expired = license.expiresAt ? new Date(license.expiresAt) < new Date() : false;
186
+
187
+ return {
188
+ tier: license.tier,
189
+ active: !expired,
190
+ expired,
191
+ email: license.email,
192
+ key: license.key,
193
+ validatedAt: license.validatedAt,
194
+ };
195
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Ticket-to-PR Assembler
3
+ * Composes a markdown PR description from ticket data, linked commits,
4
+ * requirements, and compliance coverage.
5
+ */
6
+
7
+ import { spawnSync } from 'node:child_process';
8
+ import { fetchTicket } from './jira-client.mjs';
9
+ import { extractRequirements } from './requirement-extractor.mjs';
10
+ import { findLinkedCommits } from './commit-linker.mjs';
11
+ import { runComplianceCheck } from './compliance-checker.mjs';
12
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
13
+
14
+ /**
15
+ * Detect the git remote URL using execFn.
16
+ * Returns the URL string or null if none detected.
17
+ *
18
+ * @param {Function} execFn - injectable spawnSync-compatible function
19
+ * @returns {string|null}
20
+ */
21
+ function detectRemoteUrl(execFn) {
22
+ try {
23
+ const result = execFn('git', ['remote', 'get-url', 'origin'], { encoding: 'utf8' });
24
+ if (result.status === 0 && result.stdout) {
25
+ return result.stdout.trim();
26
+ }
27
+ } catch {
28
+ // non-fatal
29
+ }
30
+ return null;
31
+ }
32
+
33
+ /**
34
+ * Build the "### Linked tickets" section from a ticket's linkedIssues.
35
+ *
36
+ * @param {Array} linkedIssues - array of { key, summary, linkType, direction }
37
+ * @returns {string} section markdown or empty string
38
+ */
39
+ function buildLinkedTicketsSection(linkedIssues) {
40
+ if (!linkedIssues || linkedIssues.length === 0) return '';
41
+
42
+ const lines = ['', '### Linked tickets'];
43
+ for (const issue of linkedIssues) {
44
+ const rel = issue.linkType ?? issue.direction ?? 'Linked';
45
+ lines.push(`- ${issue.key}: ${rel} — ${issue.summary}`);
46
+ }
47
+ return lines.join('\n');
48
+ }
49
+
50
+ /**
51
+ * Build the "### Requirements coverage" section.
52
+ *
53
+ * @param {object|null} complianceResult - result from runComplianceCheckFn or null
54
+ * @param {string[]} requirements - raw requirements list
55
+ * @returns {string} section markdown
56
+ */
57
+ function buildCoverageSection(complianceResult, requirements) {
58
+ if (complianceResult === null) {
59
+ const lines = ['', '### Requirements coverage (coverage unavailable — Pro required)'];
60
+ for (const req of requirements) {
61
+ lines.push(`- ${req}`);
62
+ }
63
+ return lines.join('\n');
64
+ }
65
+
66
+ const { coveragePercent, report } = complianceResult;
67
+ const lines = [``, `### Requirements coverage (${coveragePercent}%)`];
68
+
69
+ for (const entry of report) {
70
+ if (entry.covered) {
71
+ const loc = entry.location ? ` (${entry.location})` : '';
72
+ lines.push(`- ✔ ${entry.req}${loc}`);
73
+ } else {
74
+ lines.push(`- ✖ ${entry.req}`);
75
+ }
76
+ }
77
+
78
+ // Include any missing reqs not already in report
79
+ if (complianceResult.missing && complianceResult.missing.length > 0) {
80
+ const reportedReqs = new Set(report.map(r => r.req));
81
+ for (const missed of complianceResult.missing) {
82
+ if (!reportedReqs.has(missed)) {
83
+ lines.push(`- ✖ ${missed}`);
84
+ }
85
+ }
86
+ }
87
+
88
+ return lines.join('\n');
89
+ }
90
+
91
+ /**
92
+ * Assemble a markdown PR description for the given ticket key.
93
+ *
94
+ * @param {string} ticketKey - e.g. 'PROJ-123'
95
+ * @param {object} opts - injectable dependencies
96
+ * @param {string} [opts.configDir] - config directory path
97
+ * @param {Function} [opts.fetchTicketFn] - async fn(ticketKey, opts) → ticket
98
+ * @param {Function} [opts.extractRequirementsFn] - fn(text) → string[]
99
+ * @param {Function} [opts.findLinkedCommitsFn] - async fn(ticketKey, opts) → commit[]
100
+ * @param {Function} [opts.runComplianceCheckFn] - async fn({ brief, ticketKey, configDir }) → result|null
101
+ * @param {Function} [opts.execFn] - spawnSync-compatible for git remote detection
102
+ * @param {object} [opts.stream] - stderr for progress output (optional)
103
+ * @returns {Promise<string>} markdown PR description
104
+ */
105
+ export async function assemblePr(ticketKey, {
106
+ configDir = DEFAULT_CONFIG_DIR,
107
+ cwd = process.cwd(),
108
+ fetchTicketFn = fetchTicket,
109
+ extractRequirementsFn = extractRequirements,
110
+ findLinkedCommitsFn = findLinkedCommits,
111
+ runComplianceCheckFn = runComplianceCheck,
112
+ execFn = spawnSync,
113
+ stream,
114
+ } = {}) {
115
+ // Fetch ticket data
116
+ const ticket = await fetchTicketFn(ticketKey, { configDir });
117
+
118
+ const summary = ticket.summary ?? ticket.fields?.summary ?? '';
119
+ const description = ticket.description ?? ticket.fields?.description ?? '';
120
+ const linkedIssues = ticket.linkedIssues ?? ticket.fields?.issuelinks ?? [];
121
+
122
+ // Extract requirements from description
123
+ const requirements = extractRequirementsFn(description);
124
+
125
+ // Find linked commits
126
+ const { commits = [], branches = [] } = await findLinkedCommitsFn(ticketKey, { cwd });
127
+
128
+ // Build a minimal plain-text brief for compliance check input
129
+ const brief = `## ${ticketKey}: ${summary}\n\n${description}`;
130
+
131
+ // Run compliance check (may return null for non-Pro)
132
+ let complianceResult = null;
133
+ try {
134
+ complianceResult = await runComplianceCheckFn({ brief, ticketKey, configDir });
135
+ } catch {
136
+ // non-fatal — treat as unavailable
137
+ }
138
+
139
+ // Detect remote URL for auto-close footer
140
+ const remoteUrl = detectRemoteUrl(execFn);
141
+ const isGitHubOrGitLab = remoteUrl && (
142
+ remoteUrl.includes('github.com') || remoteUrl.includes('gitlab.com')
143
+ );
144
+
145
+ // Build output
146
+ const lines = [`## ${ticketKey}: ${summary}`, ''];
147
+
148
+ // What changed — commits are raw git --oneline strings: "<sha> <message>"
149
+ lines.push('### What changed');
150
+ if (commits.length === 0) {
151
+ lines.push('_No linked commits found._');
152
+ } else {
153
+ for (const commitLine of commits) {
154
+ lines.push(`- ${commitLine}`);
155
+ }
156
+ }
157
+
158
+ // Requirements coverage
159
+ lines.push(buildCoverageSection(complianceResult, requirements));
160
+
161
+ // Acceptance criteria
162
+ lines.push('');
163
+ lines.push('### Acceptance criteria');
164
+ if (requirements.length === 0) {
165
+ lines.push('_No acceptance criteria found._');
166
+ } else {
167
+ for (const req of requirements) {
168
+ lines.push(`- ${req}`);
169
+ }
170
+ }
171
+
172
+ // Linked tickets (optional section)
173
+ const linkedSection = buildLinkedTicketsSection(linkedIssues);
174
+ if (linkedSection) {
175
+ lines.push(linkedSection);
176
+ }
177
+
178
+ // Close footer
179
+ if (isGitHubOrGitLab) {
180
+ lines.push('');
181
+ lines.push('---');
182
+ lines.push(`Closes ${ticketKey}`);
183
+ }
184
+
185
+ return lines.join('\n');
186
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Interactive profile pickers.
3
+ * - promptProfileSelect: "profile not found" error recovery.
4
+ * - promptProfileMismatch: ticket prefix not configured in any profile.
5
+ * Both fall back to static output on non-TTY.
6
+ */
7
+
8
+ import { createStyler } from './ansi.mjs';
9
+ import { runRawSelect } from './select-prompt.mjs';
10
+
11
+ export function promptProfileSelect({ profileName, suggestion, available }, { stream = process.stderr } = {}) {
12
+ const s = createStyler({ isTTY: stream.isTTY });
13
+ const isTTY = stream.isTTY;
14
+
15
+ // Static header (written once)
16
+ stream.write('\n');
17
+ stream.write(` ${s.red('✖')} Profile ${s.bold(`"${profileName}"`)} not found.\n`);
18
+ if (suggestion) {
19
+ stream.write(`\n ${s.dim('Did you mean?')} ${s.brand(suggestion)}\n`);
20
+ }
21
+ stream.write(`\n ${s.dim('Select a profile:')}\n\n`);
22
+
23
+ // Non-TTY: just list profiles and exit
24
+ if (!isTTY || !process.stdin.setRawMode) {
25
+ for (const name of available) {
26
+ stream.write(` ${s.brand('›')} ${name}\n`);
27
+ }
28
+ stream.write('\n');
29
+ return Promise.resolve(null);
30
+ }
31
+
32
+ const initialIndex = suggestion ? Math.max(0, available.indexOf(suggestion)) : 0;
33
+
34
+ function renderFn(selected) {
35
+ const lines = [];
36
+ for (let i = 0; i < available.length; i++) {
37
+ const marker = i === selected ? s.brand('❯') : ' ';
38
+ const label = i === selected ? s.bold(s.brand(available[i])) : available[i];
39
+ lines.push(` ${marker} ${label}`);
40
+ }
41
+ lines.push('');
42
+ lines.push(` ${s.dim('↑/↓ select Enter confirm q/Esc cancel')}`);
43
+ stream.write(lines.join('\n') + '\n');
44
+ return lines.length;
45
+ }
46
+
47
+ return runRawSelect({ count: available.length, initialIndex, renderFn, stream })
48
+ .then(index => {
49
+ if (index === null) return null;
50
+ stream.write(` ${s.green('✔')} Using profile ${s.bold(s.brand(available[index]))}\n\n`);
51
+ return available[index];
52
+ });
53
+ }
54
+
55
+ /**
56
+ * Prompts the user to choose between multiple profiles that all share the
57
+ * same ticket prefix. Shown before the fetch attempt when ambiguity exists.
58
+ *
59
+ * @param {string} ticketKey e.g. "PROJ-123"
60
+ * @param {Array<{name:string, baseUrl:string|null}>} profiles matching profiles
61
+ * @param {{ stream?: NodeJS.WriteStream }} [opts]
62
+ * @returns {Promise<string|null>} chosen profile name, or null if cancelled
63
+ */
64
+ export function promptMultipleMatches(ticketKey, profiles, { stream = process.stderr } = {}) {
65
+ const s = createStyler({ isTTY: stream.isTTY });
66
+ const prefix = ticketKey.split('-')[0];
67
+
68
+ stream.write('\n');
69
+ stream.write(` ${s.cyan('●')} ${s.bold(plural(profiles.length, 'profile'))} match prefix ${s.bold(s.cyan(prefix))} — which should handle ${s.dim(ticketKey)}?\n\n`);
70
+
71
+ if (!stream.isTTY || !process.stdin.setRawMode) {
72
+ for (const p of profiles) {
73
+ const sub = p.baseUrl ? ` ${s.dim(p.baseUrl)}` : '';
74
+ stream.write(` ${s.cyan('›')} ${p.name}${sub}\n`);
75
+ }
76
+ stream.write(`\n ${s.dim(`→ Re-run with --profile=NAME to skip this prompt`)}\n\n`);
77
+ return Promise.resolve(null);
78
+ }
79
+
80
+ function renderFn(selected) {
81
+ const lines = [];
82
+ for (let i = 0; i < profiles.length; i++) {
83
+ const p = profiles[i];
84
+ const isSelected = i === selected;
85
+ const marker = isSelected ? s.blue('❯') : ' ';
86
+ const label = isSelected ? s.bold(s.blue(p.name)) : p.name;
87
+ lines.push(` ${marker} ${label}`);
88
+ if (p.baseUrl) lines.push(` ${s.dim(p.baseUrl)}`);
89
+ }
90
+ lines.push('');
91
+ lines.push(` ${s.dim('↑/↓ select Enter confirm Esc cancel')}`);
92
+ stream.write(lines.join('\n') + '\n');
93
+ return lines.length;
94
+ }
95
+
96
+ return runRawSelect({ count: profiles.length, initialIndex: 0, renderFn, stream })
97
+ .then(index => {
98
+ if (index === null) return null;
99
+ const picked = profiles[index].name;
100
+ stream.write(` ${s.green('✔')} Using ${s.bold(s.cyan(picked))}\n\n`);
101
+ return picked;
102
+ });
103
+ }
104
+
105
+ function plural(n, word) {
106
+ return `${n} ${word}${n === 1 ? '' : 's'}`;
107
+ }
108
+
109
+ /**
110
+ * Prompts the user to pick a profile when the ticket's prefix isn't
111
+ * configured in any profile. The resolved profile is pre-selected.
112
+ *
113
+ * @param {string} ticketKey e.g. "ECNT-3888"
114
+ * @param {string} currentProfile name of the auto-resolved profile
115
+ * @param {Array<{name:string, baseUrl:string|null}>} profiles all known profiles
116
+ * @param {{ stream?: NodeJS.WriteStream }} [opts]
117
+ * @returns {Promise<string|null>} picked profile name, or null to keep current
118
+ */
119
+ export function promptProfileMismatch(ticketKey, currentProfile, profiles, { stream = process.stderr } = {}) {
120
+ const s = createStyler({ isTTY: stream.isTTY });
121
+ const prefix = ticketKey.split('-')[0];
122
+
123
+ stream.write('\n');
124
+ stream.write(` ${s.yellow('⚠')} No profile is configured for ${s.bold(s.cyan(prefix))} tickets.\n`);
125
+ stream.write(` ${s.dim('Currently using:')} ${s.cyan(currentProfile)}\n`);
126
+ stream.write(`\n ${s.dim(`Which profile should handle ${ticketKey}?`)}\n\n`);
127
+
128
+ if (!stream.isTTY || !process.stdin.setRawMode) {
129
+ for (const p of profiles) {
130
+ const sub = p.baseUrl ? ` ${s.dim(p.baseUrl)}` : '';
131
+ stream.write(` ${s.cyan('›')} ${p.name}${sub}\n`);
132
+ }
133
+ stream.write(`\n ${s.dim(`→ Re-run with --profile=NAME to skip this prompt`)}\n\n`);
134
+ return Promise.resolve(null);
135
+ }
136
+
137
+ const initialIndex = Math.max(0, profiles.findIndex(p => p.name === currentProfile));
138
+
139
+ function renderFn(selected) {
140
+ const lines = [];
141
+ for (let i = 0; i < profiles.length; i++) {
142
+ const p = profiles[i];
143
+ const isSelected = i === selected;
144
+ const marker = isSelected ? s.blue('❯') : ' ';
145
+ const label = isSelected ? s.bold(s.blue(p.name)) : p.name;
146
+ lines.push(` ${marker} ${label}`);
147
+ if (p.baseUrl) lines.push(` ${s.dim(p.baseUrl)}`);
148
+ }
149
+ lines.push('');
150
+ lines.push(` ${s.dim(`↑/↓ select Enter confirm Esc continue with ${currentProfile}`)}`);
151
+ stream.write(lines.join('\n') + '\n');
152
+ return lines.length;
153
+ }
154
+
155
+ return runRawSelect({ count: profiles.length, initialIndex, renderFn, stream })
156
+ .then(index => {
157
+ if (index === null) return null;
158
+ const picked = profiles[index].name;
159
+ if (picked === currentProfile) {
160
+ stream.write(` ${s.dim(`Continuing with ${s.cyan(picked)}`)}\n\n`);
161
+ } else {
162
+ stream.write(` ${s.green('✔')} Using ${s.bold(s.cyan(picked))} for this fetch\n\n`);
163
+ }
164
+ return picked;
165
+ });
166
+ }
167
+
168
+ /**
169
+ * Simple profile selector for the connection-retry flow.
170
+ * No prefix-warning header — just "switch to which profile?".
171
+ *
172
+ * @param {string} currentProfile
173
+ * @param {Array<{name:string, baseUrl:string|null}>} profiles
174
+ * @param {{ stream?: NodeJS.WriteStream }} [opts]
175
+ * @returns {Promise<string|null>}
176
+ */
177
+ export function promptSwitchProfile(currentProfile, profiles, { stream = process.stderr } = {}) {
178
+ const s = createStyler({ isTTY: stream.isTTY });
179
+
180
+ stream.write(`\n ${s.dim('Switch to which profile?')}\n\n`);
181
+
182
+ if (!stream.isTTY || !process.stdin.setRawMode) {
183
+ for (const p of profiles) {
184
+ const sub = p.baseUrl ? ` ${s.dim(p.baseUrl)}` : '';
185
+ stream.write(` ${s.cyan('›')} ${p.name}${sub}\n`);
186
+ }
187
+ stream.write(`\n ${s.dim(`→ Re-run with --profile=NAME to specify directly`)}\n\n`);
188
+ return Promise.resolve(null);
189
+ }
190
+
191
+ const initialIndex = Math.max(0, profiles.findIndex(p => p.name === currentProfile));
192
+
193
+ function renderFn(selected) {
194
+ const lines = [];
195
+ for (let i = 0; i < profiles.length; i++) {
196
+ const p = profiles[i];
197
+ const isSelected = i === selected;
198
+ const marker = isSelected ? s.blue('❯') : ' ';
199
+ const label = isSelected ? s.bold(s.blue(p.name)) : p.name;
200
+ lines.push(` ${marker} ${label}`);
201
+ if (p.baseUrl) lines.push(` ${s.dim(p.baseUrl)}`);
202
+ }
203
+ lines.push('');
204
+ lines.push(` ${s.dim('↑/↓ select Enter confirm Esc cancel')}`);
205
+ stream.write(lines.join('\n') + '\n');
206
+ return lines.length;
207
+ }
208
+
209
+ return runRawSelect({ count: profiles.length, initialIndex, renderFn, stream })
210
+ .then(index => {
211
+ if (index === null) return null;
212
+ const picked = profiles[index].name;
213
+ stream.write(` ${s.green('✔')} Switching to ${s.bold(s.cyan(picked))}\n\n`);
214
+ return picked;
215
+ });
216
+ }