ticketlens 0.9.17 → 0.10.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/bin/ticketlens.mjs +30 -2
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +12 -1
- package/skills/jtb/scripts/lib/browser-login.mjs +21 -5
- package/skills/jtb/scripts/lib/run-collisions.mjs +3 -3
- package/skills/jtb/scripts/lib/schedule-wizard.mjs +1 -1
- package/skills/jtb/scripts/lib/team-jira-sync.mjs +185 -0
- package/skills/jtb/scripts/lib/triage-push.mjs +3 -3
- package/skills/jtb/scripts/lib/triage-share.mjs +3 -3
package/bin/ticketlens.mjs
CHANGED
|
@@ -36,6 +36,7 @@ import { promptSecret, promptText } from '../skills/jtb/scripts/lib/prompt-helpe
|
|
|
36
36
|
import { checkForUpdate, getUpdateHint } from '../skills/jtb/scripts/lib/update-check.mjs';
|
|
37
37
|
import { incrementInvocation, incrementCommand } from '../skills/jtb/scripts/lib/activity-counter.mjs';
|
|
38
38
|
import { DEFAULT_CONFIG_DIR } from '../skills/jtb/scripts/lib/config.mjs';
|
|
39
|
+
import { applyTeamConfigOnLogin, checkTeamJiraConfigUpdate } from '../skills/jtb/scripts/lib/team-jira-sync.mjs';
|
|
39
40
|
|
|
40
41
|
const TRACKED_COMMANDS = new Set([
|
|
41
42
|
'triage', 'fetch', 'get', 'compliance', 'review', 'standup',
|
|
@@ -72,12 +73,24 @@ process.on('exit', () => {
|
|
|
72
73
|
});
|
|
73
74
|
|
|
74
75
|
switch (command) {
|
|
75
|
-
case 'fetch':
|
|
76
|
-
|
|
76
|
+
case 'fetch': {
|
|
77
|
+
// Flow 2: fire team config check concurrently; banner shown after brief output
|
|
78
|
+
const _teamCheck = checkTeamJiraConfigUpdate().catch(() => null);
|
|
79
|
+
runFetch(cmdArgs).then(async () => {
|
|
80
|
+
const tcResult = await _teamCheck;
|
|
81
|
+
if (tcResult?.banner) {
|
|
82
|
+
const s = createStyler({ isTTY: process.stderr.isTTY });
|
|
83
|
+
process.stderr.write(`\n ${s.yellow('!')} ${tcResult.banner}\n`);
|
|
84
|
+
} else if (tcResult?.deleted) {
|
|
85
|
+
const s = createStyler({ isTTY: process.stderr.isTTY });
|
|
86
|
+
process.stderr.write(`\n ${s.yellow('!')} Team Jira config removed by manager — using local credentials.\n`);
|
|
87
|
+
}
|
|
88
|
+
}).catch(err => {
|
|
77
89
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
78
90
|
process.exitCode = 1;
|
|
79
91
|
});
|
|
80
92
|
break;
|
|
93
|
+
}
|
|
81
94
|
|
|
82
95
|
case 'triage':
|
|
83
96
|
runTriage(cmdArgs).catch(err => {
|
|
@@ -481,6 +494,13 @@ switch (command) {
|
|
|
481
494
|
|
|
482
495
|
saveCliToken(token);
|
|
483
496
|
process.stderr.write(`\x1b[A\r\x1b[2K ${s.green('✔')} Logged in.\n`);
|
|
497
|
+
|
|
498
|
+
// Flow 1: pull team Jira config for Pro/Team members (silently skipped for Free)
|
|
499
|
+
const tcLogin = await applyTeamConfigOnLogin().catch(() => null);
|
|
500
|
+
if (tcLogin?.ok) {
|
|
501
|
+
process.stderr.write(` ${s.dim(`○ Team Jira config applied for ${s.cyan(tcLogin.groupName)}.`)}\n`);
|
|
502
|
+
}
|
|
503
|
+
|
|
484
504
|
process.stderr.write(`\n Run ${s.cyan('ticketlens sync')} to pull your connections.\n\n`);
|
|
485
505
|
})().catch(err => {
|
|
486
506
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
@@ -541,6 +561,14 @@ switch (command) {
|
|
|
541
561
|
}
|
|
542
562
|
}
|
|
543
563
|
|
|
564
|
+
// Flow 3: also pull team Jira config update (Pro/Team); silently skipped for Free
|
|
565
|
+
const tcSync = await checkTeamJiraConfigUpdate().catch(() => null);
|
|
566
|
+
if (tcSync?.banner) {
|
|
567
|
+
process.stderr.write(`\n ${s.yellow('!')} ${tcSync.banner}\n`);
|
|
568
|
+
} else if (tcSync?.deleted) {
|
|
569
|
+
process.stderr.write(`\n ${s.yellow('!')} Team Jira config removed by manager — using local credentials.\n`);
|
|
570
|
+
}
|
|
571
|
+
|
|
544
572
|
process.stderr.write('\n');
|
|
545
573
|
})().catch(err => {
|
|
546
574
|
process.stderr.write(`Error: ${err.message}\n`);
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.10.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.
|
|
@@ -297,3 +297,14 @@ Forces a fresh fetch from Jira, bypassing the local brief cache (4-hour TTL by d
|
|
|
297
297
|
### --no-attachments
|
|
298
298
|
|
|
299
299
|
Skip downloading and reading ticket attachments. Speeds up the fetch for tickets with large or irrelevant file attachments.
|
|
300
|
+
|
|
301
|
+
### Team Jira config auto-sync (Pro/Team)
|
|
302
|
+
|
|
303
|
+
Pro and Team members whose manager has configured a shared Jira profile in the Console (`/console/admin/jira`) receive the config automatically — no manual setup needed.
|
|
304
|
+
|
|
305
|
+
- **On `ticketlens login`:** team config is fetched and written to the local profile immediately after authentication.
|
|
306
|
+
- **On every fetch (including `/jtb TICKET-KEY`):** the CLI silently checks whether the team config has been updated since last sync. If it has, the new config is applied and a banner is printed to stderr after the brief — e.g. `! Team Jira config updated by your manager.`
|
|
307
|
+
- **On `ticketlens sync`:** explicitly force-pulls the latest team config.
|
|
308
|
+
- **If the team config is removed by the manager:** the CLI retains local credentials and shows a `! Team Jira config removed by manager — using local credentials.` banner.
|
|
309
|
+
|
|
310
|
+
This is background behaviour — no action needed from you as an AI assistant. The banner may appear in CLI output; it is informational only.
|
|
@@ -3,6 +3,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
import { hostname as osHostname } from 'node:os';
|
|
5
5
|
import { getConsoleBase } from './sync.mjs';
|
|
6
|
+
import { readLicense } from './license.mjs';
|
|
6
7
|
|
|
7
8
|
const PORT_MIN = 49152;
|
|
8
9
|
const PORT_MAX = 65535;
|
|
@@ -102,20 +103,35 @@ export function startLocalServer(port, expectedState, timeoutMs = TIMEOUT_MS) {
|
|
|
102
103
|
/**
|
|
103
104
|
* Full browser login flow. Opens the authorize page in the default browser,
|
|
104
105
|
* waits for the callback, and returns the plaintext CLI token.
|
|
106
|
+
*
|
|
107
|
+
* @param {object} [opts]
|
|
108
|
+
* @param {Function} [opts.readLicenseFn] - Injectable license reader (default: readLicense)
|
|
109
|
+
* @param {Function} [opts.openBrowserFn] - Injectable browser opener (default: openBrowser)
|
|
110
|
+
* @param {Function} [opts.startServerFn] - Injectable local server (default: startLocalServer)
|
|
105
111
|
*/
|
|
106
|
-
export async function browserLogin(
|
|
112
|
+
export async function browserLogin({
|
|
113
|
+
readLicenseFn = () => { try { return readLicense(); } catch { return null; } },
|
|
114
|
+
openBrowserFn = openBrowser,
|
|
115
|
+
startServerFn = (port, state) => startLocalServer(port, state),
|
|
116
|
+
} = {}) {
|
|
107
117
|
const state = generateState();
|
|
108
118
|
const port = pickPort();
|
|
109
119
|
const hostname = osHostname();
|
|
110
120
|
|
|
111
121
|
const consoleBase = getConsoleBase();
|
|
112
|
-
|
|
122
|
+
let url = `${consoleBase}/console/auth/cli`
|
|
113
123
|
+ `?port=${port}`
|
|
114
124
|
+ `&state=${encodeURIComponent(state)}`
|
|
115
|
-
+ `&hostname=${encodeURIComponent(hostname)}
|
|
125
|
+
+ `&hostname=${encodeURIComponent(hostname)}`
|
|
126
|
+
+ `&fresh=1`;
|
|
116
127
|
|
|
117
|
-
const
|
|
118
|
-
|
|
128
|
+
const license = readLicenseFn();
|
|
129
|
+
if (license?.email) {
|
|
130
|
+
url += `&email=${encodeURIComponent(license.email)}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const tokenPromise = startServerFn(port, state);
|
|
134
|
+
openBrowserFn(url);
|
|
119
135
|
|
|
120
136
|
return tokenPromise;
|
|
121
137
|
}
|
|
@@ -2,7 +2,7 @@ import { readCliToken } from './cli-auth.mjs';
|
|
|
2
2
|
import { formatCollisions } from './collision-reporter.mjs';
|
|
3
3
|
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
4
4
|
import { apiBase, warnIfInsecure } from './api-utils.mjs';
|
|
5
|
-
import { red, yellow, bold, cyan } from './ansi.mjs';
|
|
5
|
+
import { red, yellow, bold, cyan, dim } from './ansi.mjs';
|
|
6
6
|
|
|
7
7
|
const COLLISIONS_PATH = '/v1/triage/collisions';
|
|
8
8
|
|
|
@@ -41,11 +41,11 @@ export async function runCollisions(args = [], opts = {}) {
|
|
|
41
41
|
|
|
42
42
|
if (!res.ok) {
|
|
43
43
|
if (res.status === 401) {
|
|
44
|
-
print(` ${red('✗')} Session expired
|
|
44
|
+
print(` ${red('✗')} Session expired — run ${cyan('ticketlens login')} to reconnect.\n\n`);
|
|
45
45
|
return { ok: false, status: 401 };
|
|
46
46
|
}
|
|
47
47
|
if (res.status === 403) {
|
|
48
|
-
print(` ${red('✗')} ${bold('collisions')} requires a Team license\n`);
|
|
48
|
+
print(` ${red('✗')} ${bold('collisions')} requires a Team license.\n ${dim('→ Log in with a team account:')} ${cyan('ticketlens login')}\n\n`);
|
|
49
49
|
return { ok: false, status: 403 };
|
|
50
50
|
}
|
|
51
51
|
print(` ${yellow('⚠')} Failed to fetch collisions (${res.status})\n`);
|
|
@@ -107,7 +107,7 @@ export async function runScheduleWizard({
|
|
|
107
107
|
|
|
108
108
|
if (!res.ok) {
|
|
109
109
|
if (res.status === 401) {
|
|
110
|
-
print(` ${red('✗')} Session expired
|
|
110
|
+
print(` ${red('✗')} Session expired — run ${cyan('ticketlens login')} to reconnect.\n\n`);
|
|
111
111
|
return { ok: false, status: 401 };
|
|
112
112
|
}
|
|
113
113
|
if (res.status === 403) {
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetches and applies the team Jira config shared by the manager.
|
|
3
|
+
* Non-secret fields only (URL, auth type, prefixes, project paths, triage statuses).
|
|
4
|
+
* Each member stores their own Jira credentials locally.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { readCliToken } from './cli-auth.mjs';
|
|
10
|
+
import { saveProfile, loadProfiles } from './profile-resolver.mjs';
|
|
11
|
+
import { apiBase } from './api-utils.mjs';
|
|
12
|
+
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
13
|
+
|
|
14
|
+
const META_FILE = 'team-jira-meta.json';
|
|
15
|
+
|
|
16
|
+
function metaPath(configDir) {
|
|
17
|
+
return join(configDir, META_FILE);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readMeta(configDir) {
|
|
21
|
+
const p = metaPath(configDir);
|
|
22
|
+
if (!existsSync(p)) return null;
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(readFileSync(p, 'utf8'));
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeMeta(configDir, data) {
|
|
31
|
+
writeFileSync(metaPath(configDir), JSON.stringify(data, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Fetches GET /v1/team/config from the API.
|
|
36
|
+
*
|
|
37
|
+
* @param {object} opts
|
|
38
|
+
* @param {string} [opts.configDir]
|
|
39
|
+
* @param {Function} [opts.fetcher] - injectable fetch for testing
|
|
40
|
+
* @returns {Promise<{error: string, [key: string]: any} | object>}
|
|
41
|
+
*/
|
|
42
|
+
export async function fetchTeamJiraConfig({ configDir = DEFAULT_CONFIG_DIR, fetcher = fetch } = {}) {
|
|
43
|
+
const token = readCliToken(configDir);
|
|
44
|
+
if (!token) return { error: 'no-token' };
|
|
45
|
+
|
|
46
|
+
const url = `${apiBase()}/v1/team/config`;
|
|
47
|
+
let res;
|
|
48
|
+
try {
|
|
49
|
+
res = await fetcher(url, {
|
|
50
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
51
|
+
signal: AbortSignal.timeout(10000),
|
|
52
|
+
});
|
|
53
|
+
} catch (e) {
|
|
54
|
+
return { error: 'network', message: e.message };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (res.status === 403) return { error: 'http-403' };
|
|
58
|
+
if (res.status === 404) return { error: 'http-404' };
|
|
59
|
+
if (!res.ok) return { error: `http-${res.status}` };
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
return await res.json();
|
|
63
|
+
} catch (e) {
|
|
64
|
+
return { error: 'parse', message: e.message };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Converts API team config response to a CLI profile data object.
|
|
70
|
+
*/
|
|
71
|
+
function teamConfigToCliProfile(apiData) {
|
|
72
|
+
const profile = { baseUrl: apiData.jira_base_url, auth: apiData.auth_type };
|
|
73
|
+
if (apiData.prefixes?.length) profile.ticketPrefixes = apiData.prefixes;
|
|
74
|
+
if (apiData.project_paths?.length) profile.projectPaths = apiData.project_paths;
|
|
75
|
+
if (apiData.triage_statuses?.length) profile.triageStatuses = apiData.triage_statuses;
|
|
76
|
+
return profile;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Writes team config to the named profile without touching credentials.
|
|
81
|
+
* Returns { error, message } on failure — never throws.
|
|
82
|
+
*/
|
|
83
|
+
async function applyTeamJiraConfig(groupName, profileData, configDir) {
|
|
84
|
+
try {
|
|
85
|
+
await saveProfile(groupName, profileData, null, configDir);
|
|
86
|
+
return { ok: true };
|
|
87
|
+
} catch (e) {
|
|
88
|
+
return { error: 'save-failed', message: e.message };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Silently checks whether the team Jira config has changed since last fetch.
|
|
94
|
+
* Applies the new config and returns a banner string if changed.
|
|
95
|
+
* Never throws — always returns an object.
|
|
96
|
+
*
|
|
97
|
+
* @param {object} opts
|
|
98
|
+
* @param {string} [opts.configDir]
|
|
99
|
+
* @param {Function} [opts.fetcher]
|
|
100
|
+
* @returns {Promise<{updated: boolean, deleted?: boolean, banner?: string, error?: string}>}
|
|
101
|
+
*/
|
|
102
|
+
export async function checkTeamJiraConfigUpdate({ configDir = DEFAULT_CONFIG_DIR, fetcher = fetch } = {}) {
|
|
103
|
+
const apiData = await fetchTeamJiraConfig({ configDir, fetcher });
|
|
104
|
+
|
|
105
|
+
if (apiData.error === 'http-403') return { updated: false };
|
|
106
|
+
if (apiData.error === 'http-404') return { updated: false, deleted: true };
|
|
107
|
+
if (apiData.error) return { updated: false, error: apiData.error };
|
|
108
|
+
|
|
109
|
+
const meta = readMeta(configDir);
|
|
110
|
+
|
|
111
|
+
// No change since last fetch — skip silently
|
|
112
|
+
if (meta?.updated_at && meta.updated_at === apiData.updated_at) {
|
|
113
|
+
return { updated: false };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const groupName = String(apiData.group_name ?? '').trim().slice(0, 100);
|
|
117
|
+
if (!groupName || /^(__proto__|constructor|prototype)$/.test(groupName)) {
|
|
118
|
+
return { updated: false, error: 'invalid-group-name' };
|
|
119
|
+
}
|
|
120
|
+
const profileData = teamConfigToCliProfile(apiData);
|
|
121
|
+
const applied = await applyTeamJiraConfig(groupName, profileData, configDir);
|
|
122
|
+
|
|
123
|
+
if (applied.error) return { updated: false, error: applied.error };
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
writeMeta(configDir, {
|
|
127
|
+
group_name: groupName,
|
|
128
|
+
updated_at: apiData.updated_at,
|
|
129
|
+
jira_base_url: apiData.jira_base_url,
|
|
130
|
+
auth_type: apiData.auth_type,
|
|
131
|
+
});
|
|
132
|
+
} catch {
|
|
133
|
+
return { updated: false, error: 'meta-write-failed' };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const changedFields = [];
|
|
137
|
+
if (meta?.jira_base_url && meta.jira_base_url !== apiData.jira_base_url) changedFields.push('Jira URL');
|
|
138
|
+
if (meta?.auth_type && meta.auth_type !== apiData.auth_type) changedFields.push('auth type');
|
|
139
|
+
|
|
140
|
+
const detail = changedFields.length > 0
|
|
141
|
+
? ` (${changedFields.join(', ')} changed)`
|
|
142
|
+
: '';
|
|
143
|
+
|
|
144
|
+
const banner = `⚠ Team Jira config updated by manager (${apiData.updated_at})${detail}.`;
|
|
145
|
+
|
|
146
|
+
return { updated: true, banner, groupName };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Fetches team Jira config on first login and applies it.
|
|
151
|
+
* Returns the group name so the caller can prompt for personal credentials.
|
|
152
|
+
*
|
|
153
|
+
* @param {object} opts
|
|
154
|
+
* @param {string} [opts.configDir]
|
|
155
|
+
* @param {Function} [opts.fetcher]
|
|
156
|
+
* @returns {Promise<{ok: boolean, groupName?: string, error?: string}>}
|
|
157
|
+
*/
|
|
158
|
+
export async function applyTeamConfigOnLogin({ configDir = DEFAULT_CONFIG_DIR, fetcher = fetch } = {}) {
|
|
159
|
+
const apiData = await fetchTeamJiraConfig({ configDir, fetcher });
|
|
160
|
+
|
|
161
|
+
// Free tier / no token / network error — silently skip (not a blocker for login)
|
|
162
|
+
if (apiData.error) return { ok: false, error: apiData.error };
|
|
163
|
+
|
|
164
|
+
const groupName = String(apiData.group_name ?? '').trim().slice(0, 100);
|
|
165
|
+
if (!groupName || /^(__proto__|constructor|prototype)$/.test(groupName)) {
|
|
166
|
+
return { ok: false, error: 'invalid-group-name' };
|
|
167
|
+
}
|
|
168
|
+
const profileData = teamConfigToCliProfile(apiData);
|
|
169
|
+
const applied = await applyTeamJiraConfig(groupName, profileData, configDir);
|
|
170
|
+
|
|
171
|
+
if (applied.error) return { ok: false, error: applied.error };
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
writeMeta(configDir, {
|
|
175
|
+
group_name: groupName,
|
|
176
|
+
updated_at: apiData.updated_at,
|
|
177
|
+
jira_base_url: apiData.jira_base_url,
|
|
178
|
+
auth_type: apiData.auth_type,
|
|
179
|
+
});
|
|
180
|
+
} catch {
|
|
181
|
+
return { ok: false, error: 'meta-write-failed' };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return { ok: true, groupName, authType: apiData.auth_type };
|
|
185
|
+
}
|
|
@@ -8,7 +8,7 @@ import { isLicensed } from './license.mjs';
|
|
|
8
8
|
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
9
9
|
import { apiBase, warnIfInsecure } from './api-utils.mjs';
|
|
10
10
|
import { buildTicketPayload } from './ticket-payload.mjs';
|
|
11
|
-
import { red, green, yellow, bold, cyan } from './ansi.mjs';
|
|
11
|
+
import { red, green, yellow, bold, cyan, dim } from './ansi.mjs';
|
|
12
12
|
|
|
13
13
|
const PUSH_PATH = '/v1/triage/push';
|
|
14
14
|
|
|
@@ -109,12 +109,12 @@ export async function pushTriageSnapshot({
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
if (res.status === 401) {
|
|
112
|
-
print(` ${red('✗')} Session expired
|
|
112
|
+
print(` ${red('✗')} Session expired.\n ${dim('→ Run')} ${cyan('ticketlens login')} ${dim('to reconnect.')}\n\n`);
|
|
113
113
|
return { ok: false, status: res.status };
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
if (res.status === 403) {
|
|
117
|
-
print(` ${red('✗')} ${bold('--push')} requires a Team license\n`);
|
|
117
|
+
print(` ${red('✗')} ${bold('--push')} requires a Team license.\n ${dim('→ Log in with a team account:')} ${cyan('ticketlens login')}\n\n`);
|
|
118
118
|
return { ok: false, status: res.status };
|
|
119
119
|
}
|
|
120
120
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { apiBase, warnIfInsecure } from './api-utils.mjs';
|
|
7
|
-
import { red, green, yellow, bold, cyan } from './ansi.mjs';
|
|
7
|
+
import { red, green, yellow, bold, cyan, dim } from './ansi.mjs';
|
|
8
8
|
|
|
9
9
|
const SHARE_PATH = '/v1/triage/share';
|
|
10
10
|
|
|
@@ -77,12 +77,12 @@ export async function shareTriageSnapshot({
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
if (res.status === 401) {
|
|
80
|
-
print(` ${red('✗')} Session expired
|
|
80
|
+
print(` ${red('✗')} Session expired — run ${cyan('ticketlens login')} to reconnect.\n\n`);
|
|
81
81
|
return { ok: false, status: res.status };
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
if (res.status === 403) {
|
|
85
|
-
print(` ${red('✗')} ${bold('--share')} requires a Team license\n`);
|
|
85
|
+
print(` ${red('✗')} ${bold('--share')} requires a Team license.\n ${dim('→ Log in with a team account:')} ${cyan('ticketlens login')}\n\n`);
|
|
86
86
|
return { ok: false, status: res.status };
|
|
87
87
|
}
|
|
88
88
|
|