ticketlens 0.38.13 → 0.38.15

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.
@@ -35,7 +35,7 @@ import { runStats } from '../skills/jtb/scripts/lib/run-stats.mjs';
35
35
  import { createStyler } from '../skills/jtb/scripts/lib/ansi.mjs';
36
36
  import { readCliToken, deleteCliToken } from '../skills/jtb/scripts/lib/cli-auth.mjs';
37
37
  import { runLogin } from '../skills/jtb/scripts/lib/login-flow.mjs';
38
- import { syncProfiles, reportSyncResult, getApiBase } from '../skills/jtb/scripts/lib/sync.mjs';
38
+ import { syncProfiles, reportSyncResult, getApiBase, checkTeamMembershipUpdate } from '../skills/jtb/scripts/lib/sync.mjs';
39
39
  import { checkForUpdate, getUpdateHint } from '../skills/jtb/scripts/lib/update-check.mjs';
40
40
  import { incrementInvocation, incrementCommand } from '../skills/jtb/scripts/lib/activity-counter.mjs';
41
41
  import { DEFAULT_CONFIG_DIR } from '../skills/jtb/scripts/lib/config.mjs';
@@ -101,6 +101,7 @@ switch (command) {
101
101
  case 'fetch': {
102
102
  // Flow 2: fire team config check concurrently; banner shown after brief output
103
103
  const _teamCheck = checkTeamJiraConfigUpdate().catch(() => null);
104
+ const _membershipCheck = checkTeamMembershipUpdate().catch(() => null);
104
105
  runFetch(cmdArgs).then(async () => {
105
106
  const tcResult = await _teamCheck;
106
107
  if (tcResult?.banner) {
@@ -110,6 +111,11 @@ switch (command) {
110
111
  const s = createStyler({ isTTY: process.stderr.isTTY });
111
112
  process.stderr.write(`\n ${s.yellow('!')} Team Jira config removed by manager — using local credentials.\n`);
112
113
  }
114
+ const membershipResult = await _membershipCheck;
115
+ if (membershipResult?.banner) {
116
+ const s = createStyler({ isTTY: process.stderr.isTTY });
117
+ process.stderr.write(`\n ${s.yellow('!')} ${membershipResult.banner}\n`);
118
+ }
113
119
  }).catch(err => {
114
120
  process.stderr.write(`Error: ${err.message}\n`);
115
121
  process.exitCode = 1;
@@ -364,13 +370,16 @@ switch (command) {
364
370
 
365
371
  if (cmdArgs[0] === 'set-team') {
366
372
  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');
373
+ const teamName = teamNameParts.length > 0 ? teamNameParts.join(' ') : undefined;
374
+ if (!profileName) {
375
+ process.stderr.write('Usage: ticketlens profiles set-team <profile> [team name]\n');
370
376
  process.exitCode = 1;
371
377
  break;
372
378
  }
373
- runProfilesSetTeam(profileName, teamName);
379
+ runProfilesSetTeam(profileName, teamName).catch(err => {
380
+ process.stderr.write(`Error: ${err.message}\n`);
381
+ process.exitCode = 1;
382
+ });
374
383
  break;
375
384
  }
376
385
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.38.13",
3
+ "version": "0.38.15",
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": {
@@ -12,6 +12,10 @@ import path from 'node:path';
12
12
  export const TICKET_KEY_RE = /\b[A-Z][A-Z0-9]{1,9}-\d+\b/;
13
13
  export const RECALL_FLAG_RE = /🔖\s*Recall-flag:/;
14
14
  export const NOTE_ADD_RE = /\bticketlens\s+note\s+add\b|\/jtb\s+note\b/;
15
+ // Matches the MCP tool_use name Claude Code gives an MCP server's tool call
16
+ // (mcp__<server-alias>__<tool-name>) — the server alias is whatever the user
17
+ // named it in their own .mcp.json, so only the tool-name suffix is fixed.
18
+ export const NOTE_ADD_MCP_RE = /^mcp__.+__recall_add$/;
15
19
 
16
20
  export function readStdinJson() {
17
21
  const raw = fs.readFileSync(0, 'utf8');
@@ -54,7 +58,11 @@ export function writeState(sessionId, state) {
54
58
  * Claude just flagged something every time) and sawNoteAdd gets stuck true
55
59
  * (silently disabling the Stop-hook check, since it thinks a note was
56
60
  * already added). Only count a real assistant-authored text block for the
57
- * flag, and only a real executed Bash command for note-add.
61
+ * flag, and only a real executed note-add — either the CLI (`ticketlens
62
+ * note add`/`/jtb note` via Bash) or the ticketlens MCP server's recall_add
63
+ * tool — for note-add. Missing the MCP path here was a real bug: it made
64
+ * the Stop hook nudge even after a note was genuinely captured via MCP,
65
+ * since only the Bash/CLI form was ever recognized.
58
66
  */
59
67
  export function scanTranscript(transcriptPath) {
60
68
  const result = { sawTicketKey: false, sawRecallFlag: false, sawNoteAdd: false };
@@ -86,8 +94,10 @@ export function scanTranscript(transcriptPath) {
86
94
  if (block.type === 'text' && RECALL_FLAG_RE.test(block.text ?? '')) {
87
95
  result.sawRecallFlag = true;
88
96
  }
89
- if (block.type === 'tool_use' && block.name === 'Bash' && NOTE_ADD_RE.test(block.input?.command ?? '')) {
90
- result.sawNoteAdd = true;
97
+ if (block.type === 'tool_use') {
98
+ const isCliNoteAdd = block.name === 'Bash' && NOTE_ADD_RE.test(block.input?.command ?? '');
99
+ const isMcpNoteAdd = NOTE_ADD_MCP_RE.test(block.name ?? '');
100
+ if (isCliNoteAdd || isMcpNoteAdd) result.sawNoteAdd = true;
91
101
  }
92
102
  }
93
103
  }
@@ -86,5 +86,19 @@ export const brightCyan = defaultStyler.brightCyan;
86
86
  export const brand = defaultStyler.brand;
87
87
  export const isStyled = () => defaultStyler.enabled;
88
88
 
89
- export const ANSI_RE = /\[[0-9;]*m/g;
89
+ export const ANSI_RE = /\x1b\[[0-9;]*m/g;
90
90
  export function stripAnsi(s) { return s.replace(ANSI_RE, ''); }
91
+
92
+ // Strips ALL C0/C1 control characters and DEL — not just this app's own SGR
93
+ // color codes (stripAnsi above only covers ESC [ ... m, not the broader
94
+ // escape grammar: OSC, cursor movement, etc.). Apply to any server-supplied
95
+ // string before it ever reaches a terminal write — e.g. a Console team name,
96
+ // which is attacker-controlled (any self-registered user's account name)
97
+ // and unfiltered server-side beyond a bare length check.
98
+ export function sanitizeUntrustedText(s) {
99
+ if (typeof s !== 'string') return '';
100
+ return Array.from(s).filter((ch) => {
101
+ const code = ch.codePointAt(0);
102
+ return !((code >= 0x00 && code <= 0x1f) || (code >= 0x7f && code <= 0x9f));
103
+ }).join('');
104
+ }
@@ -449,7 +449,14 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
449
449
  if (isTrustedForCurrentUrl()) updated.allowPrivateIp = true;
450
450
  else delete updated.allowPrivateIp;
451
451
 
452
- const credData = (token !== existingToken)
452
+ // Also write on an auth-type switch even when the typed token text is
453
+ // unchanged — a Server/DC PAT can double as a Basic-auth password, so
454
+ // `basic` -> `pat` with the same secret is a real flow, not contrived.
455
+ // Gating on token identity alone left the stale old-type field (apiToken
456
+ // or pat) sitting in credentials.json indefinitely; saveProfile()'s merge
457
+ // only clears the opposite field when the corresponding new key is
458
+ // actually present in credData.
459
+ const credData = (token !== existingToken || auth !== profile.auth)
453
460
  ? (auth === 'pat' ? { pat: token } : { apiToken: token })
454
461
  : {};
455
462
 
@@ -471,10 +471,11 @@ export function printProfilesHelp({ stream = process.stdout } = {}) {
471
471
  '',
472
472
  ` ${s.bold('SUBCOMMANDS')}`,
473
473
  '',
474
- ` ${s.brand('set-team')} ${s.dim('<profile> <team name>')}`,
474
+ ` ${s.brand('set-team')} ${s.dim('<profile> [team name]')}`,
475
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.`,
476
+ ` for accounts that belong to more than one team. Omit the team name`,
477
+ ` to pick one interactively from your synced teams. Stored by id,`,
478
+ ` refreshed by ${s.cyan('ticketlens login')}/${s.cyan('ticketlens sync')}.`,
478
479
  '',
479
480
  ` ${s.bold('OPTIONS')}`,
480
481
  '',
@@ -13,7 +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
+ import { resolveProfile, loadProfileRecallTeamId } from './profile-resolver.mjs';
17
17
  import { pushNote } from './recall-sync.mjs';
18
18
  import { enqueueNote, isRetryableFailure, maybeAutoFlush } from './recall-queue.mjs';
19
19
  import { incrementDraftKept, incrementDraftDeleted } from './activity-counter.mjs';
@@ -75,6 +75,7 @@ export async function runNoteAdd(cmdArgs, {
75
75
  writeNoteFn = writeNote,
76
76
  readCliTokenFn = readCliToken,
77
77
  resolveProfileFn = resolveProfile,
78
+ loadProfileRecallTeamIdFn = loadProfileRecallTeamId,
78
79
  pushNoteFn = pushNote,
79
80
  enqueueNoteFn = enqueueNote,
80
81
  isRetryableFailureFn = isRetryableFailure,
@@ -142,8 +143,9 @@ export async function runNoteAdd(cmdArgs, {
142
143
  // Field names match PushRequest's validation rules (external_id, tickets) —
143
144
  // the backend wire contract, not the local vault's internal camelCase shape.
144
145
  const profile = resolveProfileFn(ticketKey || null, { configDir, cwd: process.cwd() });
146
+ const recallTeamId = profile ? loadProfileRecallTeamIdFn(profile.name, configDir) : null;
145
147
  const payload = { external_id: id, title, tickets: ticketKeys, tags, author, sources: [], body };
146
- if (profile?.recallTeam) payload.group = profile.recallTeam;
148
+ if (recallTeamId !== null) payload.group_id = recallTeamId;
147
149
  const result = await pushNoteFn(payload, { cliToken, configDir, warn });
148
150
  if (isRetryableFailureFn(result)) {
149
151
  await enqueueNoteFn(payload, { cliToken, configDir, warn });
@@ -7,6 +7,7 @@ import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync } from 'no
7
7
  import { join } from 'node:path';
8
8
  import { homedir } from 'node:os';
9
9
  import { DEFAULT_CONFIG_DIR } from './config.mjs';
10
+ import { sanitizeUntrustedText } from './ansi.mjs';
10
11
 
11
12
  /** Simple Levenshtein distance for "did you mean" suggestions. */
12
13
  function levenshtein(a, b) {
@@ -24,7 +25,7 @@ function levenshtein(a, b) {
24
25
  return dp[m];
25
26
  }
26
27
 
27
- function findClosest(input, candidates) {
28
+ export function findClosest(input, candidates) {
28
29
  if (candidates.length === 0) return null;
29
30
  const lower = input.toLowerCase();
30
31
  let best = null;
@@ -55,13 +56,18 @@ export function saveDefault(name, configDir = DEFAULT_CONFIG_DIR) {
55
56
  invalidateProfilesCache(configDir);
56
57
  }
57
58
 
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.
59
+ // Team list synced from /v1/profiles — used to resolve a typed team name to
60
+ // its id in `ticketlens profiles set-team` without a live network call.
61
+ // Not credentials (the resulting recallTeamId is — see saveProfileRecallTeamId).
60
62
  export function saveTeams(teams, configDir = DEFAULT_CONFIG_DIR) {
61
63
  mkdirSync(configDir, { recursive: true });
62
64
  const profilesPath = join(configDir, 'profiles.json');
63
65
  const config = loadProfiles(configDir) || { profiles: {} };
64
- config.teams = teams;
66
+ // A team's name is server-supplied and traces back to another user's own
67
+ // account name (Group::createForOwner) — unfiltered, attacker-influenceable
68
+ // text. Sanitized here, at the single write path, so every consumer
69
+ // (picker, banners, set-team messages) is safe without having to remember to.
70
+ config.teams = teams.map(t => ({ ...t, name: sanitizeUntrustedText(t.name) }));
65
71
  writeFileSync(profilesPath, JSON.stringify(config, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
66
72
  invalidateProfilesCache(configDir);
67
73
  }
@@ -79,12 +85,38 @@ export function saveProfile(name, profileData, credData, configDir = DEFAULT_CON
79
85
  if (credData && Object.keys(credData).length > 0) {
80
86
  const credPath = join(configDir, 'credentials.json');
81
87
  const creds = loadCredentials(configDir);
82
- creds[name] = credData;
88
+ // Merge, not replace — a caller re-saving apiToken/pat must never silently
89
+ // drop an unrelated field already stored on this profile (e.g. recallTeamId).
90
+ // But apiToken and pat are mutually exclusive auth methods, never both
91
+ // active at once — setting one must clear a stale copy of the other, or
92
+ // switching auth type would leave a rotated/revoked credential sitting
93
+ // in the sensitive-tier file indefinitely.
94
+ const merged = { ...(creds[name] || {}), ...credData };
95
+ if ('apiToken' in credData) delete merged.pat;
96
+ if ('pat' in credData) delete merged.apiToken;
97
+ creds[name] = merged;
83
98
  writeFileSync(credPath, JSON.stringify(creds, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
84
99
  }
85
100
  invalidateProfilesCache(configDir);
86
101
  }
87
102
 
103
+ // recallTeamId lives in credentials.json (not profiles.json) — it's a
104
+ // sensitive association (which specific Console team a specific local
105
+ // profile/folder routes Recall notes to), not the non-secret profile shape
106
+ // that credentials.json's sibling fields (baseUrl, prefixes) already are.
107
+ export function saveProfileRecallTeamId(name, teamId, configDir = DEFAULT_CONFIG_DIR) {
108
+ mkdirSync(configDir, { recursive: true });
109
+ const credPath = join(configDir, 'credentials.json');
110
+ const creds = loadCredentials(configDir);
111
+ creds[name] = { ...(creds[name] || {}), recallTeamId: teamId };
112
+ writeFileSync(credPath, JSON.stringify(creds, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
113
+ invalidateProfilesCache(configDir);
114
+ }
115
+
116
+ export function loadProfileRecallTeamId(name, configDir = DEFAULT_CONFIG_DIR) {
117
+ return loadCredentials(configDir)[name]?.recallTeamId ?? null;
118
+ }
119
+
88
120
  export function deleteProfile(name, configDir = DEFAULT_CONFIG_DIR) {
89
121
  const profilesPath = join(configDir, 'profiles.json');
90
122
  const config = loadProfiles(configDir);
@@ -1,22 +1,26 @@
1
1
  /**
2
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.
3
+ * team its Recall notes should push/pull against. Stores the team's id in
4
+ * credentials.json (not profiles.json) — this is a sensitive association
5
+ * (which specific team a specific local profile/folder routes notes to),
6
+ * distinct from the non-secret profile shape profiles.json already carries.
7
+ * Resolved against the locally synced team list (refreshed by
8
+ * `ticketlens login`/`ticketlens sync`) — no live network call here, but
9
+ * never a blind write either: an unresolvable name errors instead of
10
+ * storing an id that doesn't exist.
9
11
  */
10
12
  import { createStyler } from './ansi.mjs';
11
- import { loadProfiles, loadTeams, saveProfile } from './profile-resolver.mjs';
13
+ import { loadProfiles, loadTeams, saveProfileRecallTeamId, findClosest } from './profile-resolver.mjs';
14
+ import { promptSelect } from './select-prompt.mjs';
12
15
  import { DEFAULT_CONFIG_DIR } from './config.mjs';
13
16
 
14
- export function runProfilesSetTeam(profileName, teamName, {
17
+ export async function runProfilesSetTeam(profileName, teamName, {
15
18
  configDir = DEFAULT_CONFIG_DIR,
16
19
  stream = process.stderr,
17
20
  loadProfilesFn = loadProfiles,
18
21
  loadTeamsFn = loadTeams,
19
- saveProfileFn = saveProfile,
22
+ saveProfileRecallTeamIdFn = saveProfileRecallTeamId,
23
+ promptSelectFn = promptSelect,
20
24
  } = {}) {
21
25
  const s = createStyler({ isTTY: stream.isTTY });
22
26
  const config = loadProfilesFn(configDir);
@@ -30,12 +34,36 @@ export function runProfilesSetTeam(profileName, teamName, {
30
34
 
31
35
  const teams = loadTeamsFn(configDir);
32
36
  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`);
37
+ stream.write(` ${s.red('✖')} No synced teams found. Run ${s.cyan('ticketlens sync')} first.\n`);
38
+ process.exitCode = 1;
39
+ return;
40
+ }
41
+
42
+ let team;
43
+ if (teamName) {
44
+ team = teams.find(t => t.name === teamName);
45
+ if (!team) {
46
+ const suggestion = findClosest(teamName, teams.map(t => t.name));
47
+ stream.write(` ${s.red('✖')} "${teamName}" isn't one of your synced teams.\n`);
48
+ if (suggestion) stream.write(` ${s.dim('Did you mean')} ${s.cyan(suggestion)}${s.dim('?')}\n`);
49
+ process.exitCode = 1;
50
+ return;
51
+ }
52
+ } else if (!stream.isTTY) {
53
+ stream.write(` ${s.red('✖')} No team specified. Pass a team name, or run this interactively to pick one:\n`);
54
+ for (const t of teams) stream.write(` ${s.cyan(t.name)} ${s.dim(`(${t.role})`)}\n`);
55
+ process.exitCode = 1;
56
+ return;
57
+ } else {
58
+ const items = teams.map(t => ({ label: t.name, sublabel: t.role }));
59
+ const index = await promptSelectFn(items, { stream });
60
+ if (index === null) {
61
+ stream.write(` ${s.dim('Cancelled.')}\n`);
62
+ return;
63
+ }
64
+ team = teams[index];
36
65
  }
37
66
 
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`);
67
+ saveProfileRecallTeamIdFn(profileName, team.id, configDir);
68
+ stream.write(` ${s.green('✔')} Recall notes captured under ${s.bold(s.cyan(profileName))} will target ${s.bold(s.cyan(team.name))}.\n`);
41
69
  }
@@ -15,7 +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
+ import { resolveProfile, loadProfileRecallTeamId } from './profile-resolver.mjs';
19
19
  import { pullNotes } from './recall-sync.mjs';
20
20
  import { maybeAutoFlush, flushQueue, readQueue } from './recall-queue.mjs';
21
21
  import { getEffectiveRecallSettingsWithSource } from './recall-settings-sync.mjs';
@@ -33,6 +33,7 @@ export async function runRecall(cmdArgs, {
33
33
  listNotesFn = listNotes,
34
34
  readCliTokenFn = readCliToken,
35
35
  resolveProfileFn = resolveProfile,
36
+ loadProfileRecallTeamIdFn = loadProfileRecallTeamId,
36
37
  pullNotesFn = pullNotes,
37
38
  maybeAutoFlushFn = maybeAutoFlush,
38
39
  } = {}) {
@@ -52,7 +53,8 @@ export async function runRecall(cmdArgs, {
52
53
  const cliToken = readCliTokenFn(configDir);
53
54
  if (cliToken) {
54
55
  const profile = resolveProfileFn(isTicketKey ? arg : null, { configDir, cwd: process.cwd() });
55
- await pullNotesFn({ cliToken, configDir, ttlMs: 0, group: profile?.recallTeam });
56
+ const recallTeamId = profile ? loadProfileRecallTeamIdFn(profile.name, configDir) : null;
57
+ await pullNotesFn({ cliToken, configDir, ttlMs: 0, groupId: recallTeamId ?? undefined });
56
58
  await maybeAutoFlushFn({ cliToken, configDir });
57
59
  }
58
60
 
@@ -79,17 +79,18 @@ function writeEntitlementCheckedAt(configDir, isoTimestamp, cliToken) {
79
79
  }
80
80
 
81
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.
82
+ // recallTeamId that no longer matches any of this account's Console teams
83
+ // (team deleted, or the account was removed from it) must never silently
84
+ // drop a note or return zero pull results; both retry once against the
85
+ // account's default team instead.
85
86
  async function isUnknownTeamRejection(res) {
86
87
  let reason;
87
88
  try { reason = (await res.json())?.error; } catch { /* not this case — fall through */ }
88
89
  return reason === 'Unknown team';
89
90
  }
90
91
 
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`);
92
+ function warnUnknownTeam(warn, groupId, fallbackAction) {
93
+ warn(` ${yellow('⚠')} Team #${groupId} 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
  }
94
95
 
95
96
  /**
@@ -182,9 +183,9 @@ export async function pushNote(note, {
182
183
  return { ok: false, status: res.status };
183
184
  }
184
185
 
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;
186
+ if (res.status === 422 && note.group_id !== undefined && await isUnknownTeamRejection(res)) {
187
+ warnUnknownTeam(warn, note.group_id, 'saved to your default team');
188
+ const { group_id: _omit, ...withoutGroup } = note;
188
189
  let retryRes;
189
190
  try {
190
191
  retryRes = await post(withoutGroup);
@@ -212,7 +213,7 @@ export async function pushNote(note, {
212
213
  * @param {string} [opts.configDir]
213
214
  * @param {number} [opts.ttlMs] - skip the network call if the last pull is more recent than this
214
215
  * @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
216
+ * @param {number} [opts.groupId] - explicit target team id, for accounts on more than one team
216
217
  * @param {Function} [opts.fetcher]
217
218
  * @param {Function} [opts.warn] - failure output (default: process.stderr.write)
218
219
  * @param {Function} [opts.upsertPulledNoteFn]
@@ -225,7 +226,7 @@ export async function pullNotes({
225
226
  configDir = DEFAULT_CONFIG_DIR,
226
227
  ttlMs = RECALL_PULL_TTL_MS,
227
228
  timeoutMs = 15_000,
228
- group,
229
+ groupId,
229
230
  fetcher = globalThis.fetch,
230
231
  warn = (s) => process.stderr.write(s),
231
232
  upsertPulledNoteFn = upsertPulledNote,
@@ -242,13 +243,13 @@ export async function pullNotes({
242
243
  return { ok: true, skipped: true, count: 0 };
243
244
  }
244
245
 
245
- const buildUrl = (targetGroup) => {
246
+ const buildUrl = (targetGroupId) => {
246
247
  const url = new URL(`${apiBase()}${PULL_PATH}`);
247
248
  if (lastPulledAt) url.searchParams.set('since', lastPulledAt);
248
- if (targetGroup) url.searchParams.set('group', targetGroup);
249
+ if (targetGroupId !== undefined) url.searchParams.set('group_id', targetGroupId);
249
250
  return url.toString();
250
251
  };
251
- const get = (targetGroup) => fetcher(buildUrl(targetGroup), {
252
+ const get = (targetGroupId) => fetcher(buildUrl(targetGroupId), {
252
253
  headers: { 'Authorization': `Bearer ${cliToken}`, 'Accept': 'application/json' },
253
254
  redirect: 'manual',
254
255
  signal: AbortSignal.timeout(timeoutMs),
@@ -256,13 +257,13 @@ export async function pullNotes({
256
257
 
257
258
  let res;
258
259
  try {
259
- res = await get(group);
260
+ res = await get(groupId);
260
261
  } catch {
261
262
  return { ok: false, count: 0 };
262
263
  }
263
264
 
264
- if (!res.ok && res.status === 422 && group !== undefined && await isUnknownTeamRejection(res)) {
265
- warnUnknownTeam(warn, group, 'pulling from your default team');
265
+ if (!res.ok && res.status === 422 && groupId !== undefined && await isUnknownTeamRejection(res)) {
266
+ warnUnknownTeam(warn, groupId, 'pulling from your default team');
266
267
  try {
267
268
  res = await get(undefined);
268
269
  } catch {
@@ -11,11 +11,12 @@ import {
11
11
  loadCredentials,
12
12
  saveProfile,
13
13
  saveTeams,
14
+ loadTeams,
14
15
  invalidateProfilesCache,
15
16
  } from './profile-resolver.mjs';
16
17
  import { DEFAULT_CONFIG_DIR } from './config.mjs';
17
18
  import { apiBase } from './api-utils.mjs';
18
- import { createStyler } from './ansi.mjs';
19
+ import { createStyler, sanitizeUntrustedText } from './ansi.mjs';
19
20
 
20
21
  export const getApiBase = () => apiBase();
21
22
  // Strip the api. subdomain to get the console base URL (e.g. api.ticketlens.app → ticketlens.app)
@@ -127,6 +128,69 @@ export async function syncProfiles({
127
128
  return { added, updated, unchanged, needsCredentials };
128
129
  }
129
130
 
131
+ /**
132
+ * Fire-and-forget check for team membership drift (invited to a new team,
133
+ * removed from one) — same shape as team-jira-sync.mjs's
134
+ * checkTeamJiraConfigUpdate, reusing the same /v1/profiles endpoint
135
+ * syncProfiles() already calls rather than a live-diff-only 403/404 branch.
136
+ * Never blocks or throws — a background check must never break the actual
137
+ * command it's riding alongside.
138
+ *
139
+ * @param {object} [opts]
140
+ * @param {string} [opts.configDir]
141
+ * @param {Function} [opts.fetcher]
142
+ * @returns {Promise<{updated: boolean, banner?: string}>}
143
+ */
144
+ export async function checkTeamMembershipUpdate({ configDir = DEFAULT_CONFIG_DIR, fetcher = globalThis.fetch } = {}) {
145
+ const token = readCliToken(configDir);
146
+ if (!token) return { updated: false };
147
+
148
+ let res;
149
+ try {
150
+ res = await fetcher(`${getApiBase()}/v1/profiles`, {
151
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
152
+ signal: AbortSignal.timeout(10000),
153
+ });
154
+ } catch {
155
+ return { updated: false };
156
+ }
157
+ if (!res.ok) return { updated: false };
158
+
159
+ let json;
160
+ try { json = await res.json(); } catch { return { updated: false }; }
161
+
162
+ // Sanitized immediately on receipt — remoteTeams feeds both the banner
163
+ // text below (built before saveTeams' own sanitization would ever run)
164
+ // and the persisted cache, so both must see the cleaned name.
165
+ const remoteTeams = (Array.isArray(json?.teams) ? json.teams : [])
166
+ .map(t => ({ ...t, name: sanitizeUntrustedText(t.name) }));
167
+ const localTeams = loadTeams(configDir);
168
+ const localIds = new Set(localTeams.map(t => t.id));
169
+ const remoteIds = new Set(remoteTeams.map(t => t.id));
170
+
171
+ const added = remoteTeams.filter(t => !localIds.has(t.id));
172
+ const removed = localTeams.filter(t => !remoteIds.has(t.id));
173
+ // A rename or role change (same id, different name/role) is neither an
174
+ // add nor a remove, but the stale local copy must not be left behind.
175
+ const changed = remoteTeams.some(t => {
176
+ const local = localTeams.find(l => l.id === t.id);
177
+ return local && (local.name !== t.name || local.role !== t.role);
178
+ });
179
+
180
+ if (added.length === 0 && removed.length === 0 && !changed) return { updated: false };
181
+
182
+ saveTeams(remoteTeams, configDir);
183
+
184
+ if (added.length === 0 && removed.length === 0) return { updated: false };
185
+
186
+ const lines = [
187
+ ...added.map(t => `You now have access to "${t.name}" — link a profile with: ticketlens profiles set-team <profile> "${t.name}"`),
188
+ ...removed.map(t => `You no longer have access to "${t.name}".`),
189
+ ];
190
+
191
+ return { updated: true, banner: lines.join('\n ') };
192
+ }
193
+
130
194
  /**
131
195
  * Formats a syncProfiles() result to a stream. Shared by `ticketlens sync`
132
196
  * and the onboarding hub's "Console login" step so both report the same