ticketlens 0.9.18 → 0.10.1

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.18",
3
+ "version": "0.10.1",
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.18 -->
1
+ <!-- jtb-skill-version: 0.10.1 -->
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.
@@ -122,7 +122,8 @@ export async function browserLogin({
122
122
  let url = `${consoleBase}/console/auth/cli`
123
123
  + `?port=${port}`
124
124
  + `&state=${encodeURIComponent(state)}`
125
- + `&hostname=${encodeURIComponent(hostname)}`;
125
+ + `&hostname=${encodeURIComponent(hostname)}`
126
+ + `&fresh=1`;
126
127
 
127
128
  const license = readLicenseFn();
128
129
  if (license?.email) {
@@ -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
+ }