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.
@@ -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
- runFetch(cmdArgs).catch(err => {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.9.17",
3
+ "version": "0.10.0",
4
4
  "description": "Jira CLI for developers — fetch ticket context, triage your queue, and stop tab-switching. Zero dependencies, all local.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,4 +1,4 @@
1
- <!-- jtb-skill-version: 0.9.16 -->
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
- const url = `${consoleBase}/console/auth/cli`
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 tokenPromise = startLocalServer(port, state);
118
- openBrowser(url);
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. Run ${cyan('ticketlens login')} to reconnect.\n`);
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. Run ${cyan('ticketlens login')} to reconnect.\n`);
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. Run ${cyan('ticketlens login')} to reconnect.\n`);
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. Run ${cyan('ticketlens login')} to reconnect.\n`);
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