ticketlens 0.38.12 → 0.38.13

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.
@@ -13,6 +13,7 @@ import { run as runFetch } from '../skills/jtb/scripts/fetch-ticket.mjs';
13
13
  import { run as runTriage } from '../skills/jtb/scripts/fetch-my-tickets.mjs';
14
14
  import { run as runInit } from '../skills/jtb/scripts/lib/init-wizard.mjs';
15
15
  import { runSwitch } from '../skills/jtb/scripts/lib/profile-switcher.mjs';
16
+ import { runProfilesSetTeam } from '../skills/jtb/scripts/lib/profile-set-team.mjs';
16
17
  import { run as runConfig } from '../skills/jtb/scripts/lib/config-wizard.mjs';
17
18
  import { activateLicense, checkLicense, revalidateIfStale, isLicensed, showUpgradePrompt, readLicense } from '../skills/jtb/scripts/lib/license.mjs';
18
19
  import { deleteProfile, loadProfiles, saveCredentialKey } from '../skills/jtb/scripts/lib/profile-resolver.mjs';
@@ -360,6 +361,19 @@ switch (command) {
360
361
 
361
362
  case 'profiles': {
362
363
  if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printProfilesHelp(); break; }
364
+
365
+ if (cmdArgs[0] === 'set-team') {
366
+ const [, profileName, ...teamNameParts] = cmdArgs;
367
+ const teamName = teamNameParts.join(' ');
368
+ if (!profileName || !teamName) {
369
+ process.stderr.write('Usage: ticketlens profiles set-team <profile> <team name>\n');
370
+ process.exitCode = 1;
371
+ break;
372
+ }
373
+ runProfilesSetTeam(profileName, teamName);
374
+ break;
375
+ }
376
+
363
377
  const plain = cmdArgs.includes('--plain');
364
378
  const config = loadProfiles();
365
379
  printProfiles({ config, plain });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.38.12",
3
+ "version": "0.38.13",
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": {
@@ -469,6 +469,13 @@ export function printProfilesHelp({ stream = process.stdout } = {}) {
469
469
  ` List all locally configured Jira profiles and their active status.`,
470
470
  ` Also available as ${s.cyan('ticketlens ls')}.`,
471
471
  '',
472
+ ` ${s.bold('SUBCOMMANDS')}`,
473
+ '',
474
+ ` ${s.brand('set-team')} ${s.dim('<profile> <team name>')}`,
475
+ ` Route this profile's Recall notes to a specific Console team —`,
476
+ ` for accounts that belong to more than one team. Validated against`,
477
+ ` teams synced by ${s.cyan('ticketlens login')}/${s.cyan('ticketlens sync')}, but never blocked by a stale list.`,
478
+ '',
472
479
  ` ${s.bold('OPTIONS')}`,
473
480
  '',
474
481
  ` ${s.brand('--plain')} Tab-separated output ${s.dim('(for scripting)')}`,
@@ -479,6 +486,7 @@ export function printProfilesHelp({ stream = process.stdout } = {}) {
479
486
  ` ${s.dim('$')} ticketlens profiles`,
480
487
  ` ${s.dim('$')} ticketlens ls`,
481
488
  ` ${s.dim('$')} ticketlens profiles --plain`,
489
+ ` ${s.dim('$')} ticketlens profiles set-team advent "Team Manager's Team"`,
482
490
  '',
483
491
  ];
484
492
  stream.write(lines.join('\n') + '\n');
@@ -10,6 +10,7 @@ import { browserLogin } from './browser-login.mjs';
10
10
  import { saveCliToken } from './cli-auth.mjs';
11
11
  import { getApiBase, getConsoleBase } from './sync.mjs';
12
12
  import { applyTeamConfigOnLogin } from './team-jira-sync.mjs';
13
+ import { saveTeams } from './profile-resolver.mjs';
13
14
 
14
15
  export async function runLogin({
15
16
  manual = false,
@@ -19,6 +20,7 @@ export async function runLogin({
19
20
  fetchFn = globalThis.fetch,
20
21
  promptSecretFn = promptSecret,
21
22
  saveCliTokenFn = saveCliToken,
23
+ saveTeamsFn = saveTeams,
22
24
  applyTeamConfigOnLoginFn = applyTeamConfigOnLogin,
23
25
  } = {}) {
24
26
  const s = createStyler({ isTTY: stream.isTTY });
@@ -83,12 +85,15 @@ export async function runLogin({
83
85
  return;
84
86
  }
85
87
 
86
- let tier;
88
+ let tier, teams;
87
89
  try {
88
- tier = (await res.json())?.tier;
89
- } catch { /* tier is best-effort — token is still valid */ }
90
+ const body = await res.json();
91
+ tier = body?.tier;
92
+ teams = body?.teams;
93
+ } catch { /* tier/teams are best-effort — token is still valid */ }
90
94
 
91
95
  saveCliTokenFn(token, undefined, tier);
96
+ saveTeamsFn(Array.isArray(teams) ? teams : []);
92
97
  stream.write(`\x1b[A\r\x1b[2K ${s.green('✔')} Logged in.\n`);
93
98
 
94
99
  // Flow 1: pull team Jira config for Pro/Team members (silently skipped for Free)
@@ -13,6 +13,7 @@ import { scanForSecrets } from './secret-scanner.mjs';
13
13
  import { checkNoteStructure } from './note-structural-check.mjs';
14
14
  import { writeNote, patchNoteBody, deleteNote, deleteNoteAnyPrefix, rebuildIndex } from './recall-vault.mjs';
15
15
  import { readCliToken } from './cli-auth.mjs';
16
+ import { resolveProfile } from './profile-resolver.mjs';
16
17
  import { pushNote } from './recall-sync.mjs';
17
18
  import { enqueueNote, isRetryableFailure, maybeAutoFlush } from './recall-queue.mjs';
18
19
  import { incrementDraftKept, incrementDraftDeleted } from './activity-counter.mjs';
@@ -73,6 +74,7 @@ export async function runNoteAdd(cmdArgs, {
73
74
  scanForSecretsFn = scanForSecrets,
74
75
  writeNoteFn = writeNote,
75
76
  readCliTokenFn = readCliToken,
77
+ resolveProfileFn = resolveProfile,
76
78
  pushNoteFn = pushNote,
77
79
  enqueueNoteFn = enqueueNote,
78
80
  isRetryableFailureFn = isRetryableFailure,
@@ -139,7 +141,9 @@ export async function runNoteAdd(cmdArgs, {
139
141
  const warn = (s) => stream.write(s);
140
142
  // Field names match PushRequest's validation rules (external_id, tickets) —
141
143
  // the backend wire contract, not the local vault's internal camelCase shape.
144
+ const profile = resolveProfileFn(ticketKey || null, { configDir, cwd: process.cwd() });
142
145
  const payload = { external_id: id, title, tickets: ticketKeys, tags, author, sources: [], body };
146
+ if (profile?.recallTeam) payload.group = profile.recallTeam;
143
147
  const result = await pushNoteFn(payload, { cliToken, configDir, warn });
144
148
  if (isRetryableFailureFn(result)) {
145
149
  await enqueueNoteFn(payload, { cliToken, configDir, warn });
@@ -55,6 +55,21 @@ export function saveDefault(name, configDir = DEFAULT_CONFIG_DIR) {
55
55
  invalidateProfilesCache(configDir);
56
56
  }
57
57
 
58
+ // Team list synced from /v1/profiles — used to validate `recallTeam` on a
59
+ // profile without a live network call at push/pull time. Not credentials.
60
+ export function saveTeams(teams, configDir = DEFAULT_CONFIG_DIR) {
61
+ mkdirSync(configDir, { recursive: true });
62
+ const profilesPath = join(configDir, 'profiles.json');
63
+ const config = loadProfiles(configDir) || { profiles: {} };
64
+ config.teams = teams;
65
+ writeFileSync(profilesPath, JSON.stringify(config, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
66
+ invalidateProfilesCache(configDir);
67
+ }
68
+
69
+ export function loadTeams(configDir = DEFAULT_CONFIG_DIR) {
70
+ return loadProfiles(configDir)?.teams ?? [];
71
+ }
72
+
58
73
  export function saveProfile(name, profileData, credData, configDir = DEFAULT_CONFIG_DIR) {
59
74
  mkdirSync(configDir, { recursive: true });
60
75
  const profilesPath = join(configDir, 'profiles.json');
@@ -0,0 +1,41 @@
1
+ /**
2
+ * ticketlens profiles set-team — maps a local Jira profile to the Console
3
+ * team its Recall notes should push/pull against. Validates the name
4
+ * against the locally synced team list (no live network call — that list
5
+ * is refreshed by `ticketlens login`/`ticketlens sync`), but never blocks
6
+ * the write on a stale/missing list or a name mismatch: the hard check is
7
+ * server-side at push/pull time (RecallTeamResolver), this is a local
8
+ * convenience check only.
9
+ */
10
+ import { createStyler } from './ansi.mjs';
11
+ import { loadProfiles, loadTeams, saveProfile } from './profile-resolver.mjs';
12
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
13
+
14
+ export function runProfilesSetTeam(profileName, teamName, {
15
+ configDir = DEFAULT_CONFIG_DIR,
16
+ stream = process.stderr,
17
+ loadProfilesFn = loadProfiles,
18
+ loadTeamsFn = loadTeams,
19
+ saveProfileFn = saveProfile,
20
+ } = {}) {
21
+ const s = createStyler({ isTTY: stream.isTTY });
22
+ const config = loadProfilesFn(configDir);
23
+ const profile = config?.profiles?.[profileName];
24
+
25
+ if (!profile) {
26
+ stream.write(` ${s.red('✖')} No profile named "${profileName}". Run ${s.cyan('ticketlens profiles')} to see available profiles.\n`);
27
+ process.exitCode = 1;
28
+ return;
29
+ }
30
+
31
+ const teams = loadTeamsFn(configDir);
32
+ if (teams.length === 0) {
33
+ stream.write(` ${s.yellow('⚠')} No synced teams found — run ${s.cyan('ticketlens sync')} first to validate this name.\n`);
34
+ } else if (!teams.some(t => t.name === teamName)) {
35
+ stream.write(` ${s.yellow('⚠')} "${teamName}" isn't one of your synced teams — check spelling, or run ${s.cyan('ticketlens sync')} to refresh.\n`);
36
+ }
37
+
38
+ // Must merge, not replace — saveProfile() overwrites the whole profile object.
39
+ saveProfileFn(profileName, { ...profile, recallTeam: teamName }, null, configDir);
40
+ stream.write(` ${s.green('✔')} Recall notes captured under ${s.bold(s.cyan(profileName))} will target ${s.bold(s.cyan(teamName))}.\n`);
41
+ }
@@ -15,6 +15,7 @@ import { TICKET_KEY_PATTERN } from './cli.mjs';
15
15
  import { isLicensed, showUpgradePrompt } from './license.mjs';
16
16
  import { listNotes } from './recall-vault.mjs';
17
17
  import { readCliToken } from './cli-auth.mjs';
18
+ import { resolveProfile } from './profile-resolver.mjs';
18
19
  import { pullNotes } from './recall-sync.mjs';
19
20
  import { maybeAutoFlush, flushQueue, readQueue } from './recall-queue.mjs';
20
21
  import { getEffectiveRecallSettingsWithSource } from './recall-settings-sync.mjs';
@@ -31,6 +32,7 @@ export async function runRecall(cmdArgs, {
31
32
  isLicensedFn = isLicensed,
32
33
  listNotesFn = listNotes,
33
34
  readCliTokenFn = readCliToken,
35
+ resolveProfileFn = resolveProfile,
34
36
  pullNotesFn = pullNotes,
35
37
  maybeAutoFlushFn = maybeAutoFlush,
36
38
  } = {}) {
@@ -45,13 +47,16 @@ export async function runRecall(cmdArgs, {
45
47
  return { ok: false };
46
48
  }
47
49
 
50
+ const isTicketKey = TICKET_KEY_PATTERN.test(arg);
51
+
48
52
  const cliToken = readCliTokenFn(configDir);
49
53
  if (cliToken) {
50
- await pullNotesFn({ cliToken, configDir, ttlMs: 0 });
54
+ const profile = resolveProfileFn(isTicketKey ? arg : null, { configDir, cwd: process.cwd() });
55
+ await pullNotesFn({ cliToken, configDir, ttlMs: 0, group: profile?.recallTeam });
51
56
  await maybeAutoFlushFn({ cliToken, configDir });
52
57
  }
53
58
 
54
- const filter = TICKET_KEY_PATTERN.test(arg) ? { ticketKey: arg } : { query: arg };
59
+ const filter = isTicketKey ? { ticketKey: arg } : { query: arg };
55
60
  const results = listNotesFn(filter, { configDir });
56
61
 
57
62
  const styled = !cmdArgs.includes('--plain') && !!stream.isTTY;
@@ -78,6 +78,20 @@ function writeEntitlementCheckedAt(configDir, isoTimestamp, cliToken) {
78
78
  }
79
79
  }
80
80
 
81
+ // Shared by pushNote's and pullNotes' 422 handling — a profile's configured
82
+ // recallTeam that doesn't match any of this account's Console teams
83
+ // (renamed/removed/typo) must never silently drop a note or return zero
84
+ // pull results; both retry once against the account's default team instead.
85
+ async function isUnknownTeamRejection(res) {
86
+ let reason;
87
+ try { reason = (await res.json())?.error; } catch { /* not this case — fall through */ }
88
+ return reason === 'Unknown team';
89
+ }
90
+
91
+ function warnUnknownTeam(warn, groupName, fallbackAction) {
92
+ warn(` ${yellow('⚠')} "${groupName}" isn't a team on your account — ${fallbackAction} instead.\n ${dim('→ Run')} ${cyan('ticketlens profiles set-team')} ${dim('to fix this profile\'s team mapping.')}\n`);
93
+ }
94
+
81
95
  /**
82
96
  * POSTs one locally-authored note to the team backend.
83
97
  *
@@ -116,23 +130,25 @@ export async function pushNote(note, {
116
130
 
117
131
  warnIfInsecure(apiBase(), warn);
118
132
 
133
+ const post = (payload) => fetcher(`${apiBase()}${PUSH_PATH}`, {
134
+ method: 'POST',
135
+ headers: {
136
+ 'Content-Type': 'application/json',
137
+ 'Accept': 'application/json',
138
+ 'Authorization': `Bearer ${cliToken}`,
139
+ },
140
+ body: JSON.stringify(payload),
141
+ // Without Accept: application/json, a Laravel validation failure 302-redirects
142
+ // instead of returning JSON, and fetch's default redirect:'follow' silently
143
+ // turns that into a followed 200 — a false "pushed" the user never sees fail.
144
+ // 'manual' makes any unexpected redirect surface as a non-ok response instead.
145
+ redirect: 'manual',
146
+ signal: AbortSignal.timeout(timeoutMs),
147
+ });
148
+
119
149
  let res;
120
150
  try {
121
- res = await fetcher(`${apiBase()}${PUSH_PATH}`, {
122
- method: 'POST',
123
- headers: {
124
- 'Content-Type': 'application/json',
125
- 'Accept': 'application/json',
126
- 'Authorization': `Bearer ${cliToken}`,
127
- },
128
- body: JSON.stringify(note),
129
- // Without Accept: application/json, a Laravel validation failure 302-redirects
130
- // instead of returning JSON, and fetch's default redirect:'follow' silently
131
- // turns that into a followed 200 — a false "pushed" the user never sees fail.
132
- // 'manual' makes any unexpected redirect surface as a non-ok response instead.
133
- redirect: 'manual',
134
- signal: AbortSignal.timeout(timeoutMs),
135
- });
151
+ res = await post(note);
136
152
  } catch {
137
153
  warn(` ${yellow('⚠')} Could not sync note to your team (network error) — saved locally and queued to retry automatically.\n`);
138
154
  return { ok: false };
@@ -166,6 +182,19 @@ export async function pushNote(note, {
166
182
  return { ok: false, status: res.status };
167
183
  }
168
184
 
185
+ if (res.status === 422 && note.group !== undefined && await isUnknownTeamRejection(res)) {
186
+ warnUnknownTeam(warn, note.group, 'saved to your default team');
187
+ const { group: _omit, ...withoutGroup } = note;
188
+ let retryRes;
189
+ try {
190
+ retryRes = await post(withoutGroup);
191
+ } catch {
192
+ warn(` ${yellow('⚠')} Could not sync note to your team (network error) — saved locally and queued to retry automatically.\n`);
193
+ return { ok: false };
194
+ }
195
+ return retryRes.ok ? { ok: true, status: retryRes.status } : { ok: false, status: retryRes.status };
196
+ }
197
+
169
198
  warn(` ${yellow('⚠')} Could not sync note to your team (${res.status}) — saved locally only.\n`);
170
199
  return { ok: false, status: res.status };
171
200
  }
@@ -183,7 +212,9 @@ export async function pushNote(note, {
183
212
  * @param {string} [opts.configDir]
184
213
  * @param {number} [opts.ttlMs] - skip the network call if the last pull is more recent than this
185
214
  * @param {number} [opts.timeoutMs] - request timeout; short for a passive/background pull, long for an explicit one
215
+ * @param {string} [opts.group] - explicit target team name, for accounts on more than one team
186
216
  * @param {Function} [opts.fetcher]
217
+ * @param {Function} [opts.warn] - failure output (default: process.stderr.write)
187
218
  * @param {Function} [opts.upsertPulledNoteFn]
188
219
  * @param {Function} [opts.rebuildIndexFn]
189
220
  * @param {() => number} [opts.now]
@@ -194,7 +225,9 @@ export async function pullNotes({
194
225
  configDir = DEFAULT_CONFIG_DIR,
195
226
  ttlMs = RECALL_PULL_TTL_MS,
196
227
  timeoutMs = 15_000,
228
+ group,
197
229
  fetcher = globalThis.fetch,
230
+ warn = (s) => process.stderr.write(s),
198
231
  upsertPulledNoteFn = upsertPulledNote,
199
232
  rebuildIndexFn = rebuildIndex,
200
233
  deleteNoteFn = deleteNote,
@@ -209,20 +242,34 @@ export async function pullNotes({
209
242
  return { ok: true, skipped: true, count: 0 };
210
243
  }
211
244
 
212
- const url = new URL(`${apiBase()}${PULL_PATH}`);
213
- if (lastPulledAt) url.searchParams.set('since', lastPulledAt);
245
+ const buildUrl = (targetGroup) => {
246
+ const url = new URL(`${apiBase()}${PULL_PATH}`);
247
+ if (lastPulledAt) url.searchParams.set('since', lastPulledAt);
248
+ if (targetGroup) url.searchParams.set('group', targetGroup);
249
+ return url.toString();
250
+ };
251
+ const get = (targetGroup) => fetcher(buildUrl(targetGroup), {
252
+ headers: { 'Authorization': `Bearer ${cliToken}`, 'Accept': 'application/json' },
253
+ redirect: 'manual',
254
+ signal: AbortSignal.timeout(timeoutMs),
255
+ });
214
256
 
215
257
  let res;
216
258
  try {
217
- res = await fetcher(url.toString(), {
218
- headers: { 'Authorization': `Bearer ${cliToken}`, 'Accept': 'application/json' },
219
- redirect: 'manual',
220
- signal: AbortSignal.timeout(timeoutMs),
221
- });
259
+ res = await get(group);
222
260
  } catch {
223
261
  return { ok: false, count: 0 };
224
262
  }
225
263
 
264
+ if (!res.ok && res.status === 422 && group !== undefined && await isUnknownTeamRejection(res)) {
265
+ warnUnknownTeam(warn, group, 'pulling from your default team');
266
+ try {
267
+ res = await get(undefined);
268
+ } catch {
269
+ return { ok: false, count: 0 };
270
+ }
271
+ }
272
+
226
273
  if (!res.ok) {
227
274
  return { ok: false, status: res.status, count: 0 };
228
275
  }
@@ -10,6 +10,7 @@ import {
10
10
  loadProfiles,
11
11
  loadCredentials,
12
12
  saveProfile,
13
+ saveTeams,
13
14
  invalidateProfilesCache,
14
15
  } from './profile-resolver.mjs';
15
16
  import { DEFAULT_CONFIG_DIR } from './config.mjs';
@@ -89,6 +90,7 @@ export async function syncProfiles({
89
90
  try { json = await res.json(); } catch { return { error: 'invalid-json' }; }
90
91
 
91
92
  if (typeof json?.tier === 'string') saveCliTokenTier(json.tier, configDir);
93
+ saveTeams(Array.isArray(json?.teams) ? json.teams : [], configDir);
92
94
 
93
95
  const remoteProfiles = Array.isArray(json?.profiles) ? json.profiles : [];
94
96
  const localConfig = loadProfiles(configDir) || { profiles: {} };