ticketlens 0.38.12 → 0.38.14

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';
@@ -34,7 +35,7 @@ import { runStats } from '../skills/jtb/scripts/lib/run-stats.mjs';
34
35
  import { createStyler } from '../skills/jtb/scripts/lib/ansi.mjs';
35
36
  import { readCliToken, deleteCliToken } from '../skills/jtb/scripts/lib/cli-auth.mjs';
36
37
  import { runLogin } from '../skills/jtb/scripts/lib/login-flow.mjs';
37
- import { syncProfiles, reportSyncResult, getApiBase } from '../skills/jtb/scripts/lib/sync.mjs';
38
+ import { syncProfiles, reportSyncResult, getApiBase, checkTeamMembershipUpdate } from '../skills/jtb/scripts/lib/sync.mjs';
38
39
  import { checkForUpdate, getUpdateHint } from '../skills/jtb/scripts/lib/update-check.mjs';
39
40
  import { incrementInvocation, incrementCommand } from '../skills/jtb/scripts/lib/activity-counter.mjs';
40
41
  import { DEFAULT_CONFIG_DIR } from '../skills/jtb/scripts/lib/config.mjs';
@@ -100,6 +101,7 @@ switch (command) {
100
101
  case 'fetch': {
101
102
  // Flow 2: fire team config check concurrently; banner shown after brief output
102
103
  const _teamCheck = checkTeamJiraConfigUpdate().catch(() => null);
104
+ const _membershipCheck = checkTeamMembershipUpdate().catch(() => null);
103
105
  runFetch(cmdArgs).then(async () => {
104
106
  const tcResult = await _teamCheck;
105
107
  if (tcResult?.banner) {
@@ -109,6 +111,11 @@ switch (command) {
109
111
  const s = createStyler({ isTTY: process.stderr.isTTY });
110
112
  process.stderr.write(`\n ${s.yellow('!')} Team Jira config removed by manager — using local credentials.\n`);
111
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
+ }
112
119
  }).catch(err => {
113
120
  process.stderr.write(`Error: ${err.message}\n`);
114
121
  process.exitCode = 1;
@@ -360,6 +367,22 @@ switch (command) {
360
367
 
361
368
  case 'profiles': {
362
369
  if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printProfilesHelp(); break; }
370
+
371
+ if (cmdArgs[0] === 'set-team') {
372
+ const [, profileName, ...teamNameParts] = cmdArgs;
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');
376
+ process.exitCode = 1;
377
+ break;
378
+ }
379
+ runProfilesSetTeam(profileName, teamName).catch(err => {
380
+ process.stderr.write(`Error: ${err.message}\n`);
381
+ process.exitCode = 1;
382
+ });
383
+ break;
384
+ }
385
+
363
386
  const plain = cmdArgs.includes('--plain');
364
387
  const config = loadProfiles();
365
388
  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.14",
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": {
@@ -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
 
@@ -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, loadProfileRecallTeamId } 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,8 @@ export async function runNoteAdd(cmdArgs, {
73
74
  scanForSecretsFn = scanForSecrets,
74
75
  writeNoteFn = writeNote,
75
76
  readCliTokenFn = readCliToken,
77
+ resolveProfileFn = resolveProfile,
78
+ loadProfileRecallTeamIdFn = loadProfileRecallTeamId,
76
79
  pushNoteFn = pushNote,
77
80
  enqueueNoteFn = enqueueNote,
78
81
  isRetryableFailureFn = isRetryableFailure,
@@ -139,7 +142,10 @@ export async function runNoteAdd(cmdArgs, {
139
142
  const warn = (s) => stream.write(s);
140
143
  // Field names match PushRequest's validation rules (external_id, tickets) —
141
144
  // the backend wire contract, not the local vault's internal camelCase shape.
145
+ const profile = resolveProfileFn(ticketKey || null, { configDir, cwd: process.cwd() });
146
+ const recallTeamId = profile ? loadProfileRecallTeamIdFn(profile.name, configDir) : null;
142
147
  const payload = { external_id: id, title, tickets: ticketKeys, tags, author, sources: [], body };
148
+ if (recallTeamId !== null) payload.group_id = recallTeamId;
143
149
  const result = await pushNoteFn(payload, { cliToken, configDir, warn });
144
150
  if (isRetryableFailureFn(result)) {
145
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,6 +56,26 @@ export function saveDefault(name, configDir = DEFAULT_CONFIG_DIR) {
55
56
  invalidateProfilesCache(configDir);
56
57
  }
57
58
 
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).
62
+ export function saveTeams(teams, configDir = DEFAULT_CONFIG_DIR) {
63
+ mkdirSync(configDir, { recursive: true });
64
+ const profilesPath = join(configDir, 'profiles.json');
65
+ const config = loadProfiles(configDir) || { profiles: {} };
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) }));
71
+ writeFileSync(profilesPath, JSON.stringify(config, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
72
+ invalidateProfilesCache(configDir);
73
+ }
74
+
75
+ export function loadTeams(configDir = DEFAULT_CONFIG_DIR) {
76
+ return loadProfiles(configDir)?.teams ?? [];
77
+ }
78
+
58
79
  export function saveProfile(name, profileData, credData, configDir = DEFAULT_CONFIG_DIR) {
59
80
  mkdirSync(configDir, { recursive: true });
60
81
  const profilesPath = join(configDir, 'profiles.json');
@@ -64,12 +85,38 @@ export function saveProfile(name, profileData, credData, configDir = DEFAULT_CON
64
85
  if (credData && Object.keys(credData).length > 0) {
65
86
  const credPath = join(configDir, 'credentials.json');
66
87
  const creds = loadCredentials(configDir);
67
- 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;
68
98
  writeFileSync(credPath, JSON.stringify(creds, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
69
99
  }
70
100
  invalidateProfilesCache(configDir);
71
101
  }
72
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
+
73
120
  export function deleteProfile(name, configDir = DEFAULT_CONFIG_DIR) {
74
121
  const profilesPath = join(configDir, 'profiles.json');
75
122
  const config = loadProfiles(configDir);
@@ -0,0 +1,69 @@
1
+ /**
2
+ * ticketlens profiles set-team — maps a local Jira profile to the Console
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.
11
+ */
12
+ import { createStyler } from './ansi.mjs';
13
+ import { loadProfiles, loadTeams, saveProfileRecallTeamId, findClosest } from './profile-resolver.mjs';
14
+ import { promptSelect } from './select-prompt.mjs';
15
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
16
+
17
+ export async function runProfilesSetTeam(profileName, teamName, {
18
+ configDir = DEFAULT_CONFIG_DIR,
19
+ stream = process.stderr,
20
+ loadProfilesFn = loadProfiles,
21
+ loadTeamsFn = loadTeams,
22
+ saveProfileRecallTeamIdFn = saveProfileRecallTeamId,
23
+ promptSelectFn = promptSelect,
24
+ } = {}) {
25
+ const s = createStyler({ isTTY: stream.isTTY });
26
+ const config = loadProfilesFn(configDir);
27
+ const profile = config?.profiles?.[profileName];
28
+
29
+ if (!profile) {
30
+ stream.write(` ${s.red('✖')} No profile named "${profileName}". Run ${s.cyan('ticketlens profiles')} to see available profiles.\n`);
31
+ process.exitCode = 1;
32
+ return;
33
+ }
34
+
35
+ const teams = loadTeamsFn(configDir);
36
+ if (teams.length === 0) {
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];
65
+ }
66
+
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`);
69
+ }
@@ -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, loadProfileRecallTeamId } 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,8 @@ export async function runRecall(cmdArgs, {
31
32
  isLicensedFn = isLicensed,
32
33
  listNotesFn = listNotes,
33
34
  readCliTokenFn = readCliToken,
35
+ resolveProfileFn = resolveProfile,
36
+ loadProfileRecallTeamIdFn = loadProfileRecallTeamId,
34
37
  pullNotesFn = pullNotes,
35
38
  maybeAutoFlushFn = maybeAutoFlush,
36
39
  } = {}) {
@@ -45,13 +48,17 @@ export async function runRecall(cmdArgs, {
45
48
  return { ok: false };
46
49
  }
47
50
 
51
+ const isTicketKey = TICKET_KEY_PATTERN.test(arg);
52
+
48
53
  const cliToken = readCliTokenFn(configDir);
49
54
  if (cliToken) {
50
- await pullNotesFn({ cliToken, configDir, ttlMs: 0 });
55
+ const profile = resolveProfileFn(isTicketKey ? arg : null, { configDir, cwd: process.cwd() });
56
+ const recallTeamId = profile ? loadProfileRecallTeamIdFn(profile.name, configDir) : null;
57
+ await pullNotesFn({ cliToken, configDir, ttlMs: 0, groupId: recallTeamId ?? undefined });
51
58
  await maybeAutoFlushFn({ cliToken, configDir });
52
59
  }
53
60
 
54
- const filter = TICKET_KEY_PATTERN.test(arg) ? { ticketKey: arg } : { query: arg };
61
+ const filter = isTicketKey ? { ticketKey: arg } : { query: arg };
55
62
  const results = listNotesFn(filter, { configDir });
56
63
 
57
64
  const styled = !cmdArgs.includes('--plain') && !!stream.isTTY;
@@ -78,6 +78,21 @@ 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
+ // 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.
86
+ async function isUnknownTeamRejection(res) {
87
+ let reason;
88
+ try { reason = (await res.json())?.error; } catch { /* not this case — fall through */ }
89
+ return reason === 'Unknown team';
90
+ }
91
+
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`);
94
+ }
95
+
81
96
  /**
82
97
  * POSTs one locally-authored note to the team backend.
83
98
  *
@@ -116,23 +131,25 @@ export async function pushNote(note, {
116
131
 
117
132
  warnIfInsecure(apiBase(), warn);
118
133
 
134
+ const post = (payload) => fetcher(`${apiBase()}${PUSH_PATH}`, {
135
+ method: 'POST',
136
+ headers: {
137
+ 'Content-Type': 'application/json',
138
+ 'Accept': 'application/json',
139
+ 'Authorization': `Bearer ${cliToken}`,
140
+ },
141
+ body: JSON.stringify(payload),
142
+ // Without Accept: application/json, a Laravel validation failure 302-redirects
143
+ // instead of returning JSON, and fetch's default redirect:'follow' silently
144
+ // turns that into a followed 200 — a false "pushed" the user never sees fail.
145
+ // 'manual' makes any unexpected redirect surface as a non-ok response instead.
146
+ redirect: 'manual',
147
+ signal: AbortSignal.timeout(timeoutMs),
148
+ });
149
+
119
150
  let res;
120
151
  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
- });
152
+ res = await post(note);
136
153
  } catch {
137
154
  warn(` ${yellow('⚠')} Could not sync note to your team (network error) — saved locally and queued to retry automatically.\n`);
138
155
  return { ok: false };
@@ -166,6 +183,19 @@ export async function pushNote(note, {
166
183
  return { ok: false, status: res.status };
167
184
  }
168
185
 
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;
189
+ let retryRes;
190
+ try {
191
+ retryRes = await post(withoutGroup);
192
+ } catch {
193
+ warn(` ${yellow('⚠')} Could not sync note to your team (network error) — saved locally and queued to retry automatically.\n`);
194
+ return { ok: false };
195
+ }
196
+ return retryRes.ok ? { ok: true, status: retryRes.status } : { ok: false, status: retryRes.status };
197
+ }
198
+
169
199
  warn(` ${yellow('⚠')} Could not sync note to your team (${res.status}) — saved locally only.\n`);
170
200
  return { ok: false, status: res.status };
171
201
  }
@@ -183,7 +213,9 @@ export async function pushNote(note, {
183
213
  * @param {string} [opts.configDir]
184
214
  * @param {number} [opts.ttlMs] - skip the network call if the last pull is more recent than this
185
215
  * @param {number} [opts.timeoutMs] - request timeout; short for a passive/background pull, long for an explicit one
216
+ * @param {number} [opts.groupId] - explicit target team id, for accounts on more than one team
186
217
  * @param {Function} [opts.fetcher]
218
+ * @param {Function} [opts.warn] - failure output (default: process.stderr.write)
187
219
  * @param {Function} [opts.upsertPulledNoteFn]
188
220
  * @param {Function} [opts.rebuildIndexFn]
189
221
  * @param {() => number} [opts.now]
@@ -194,7 +226,9 @@ export async function pullNotes({
194
226
  configDir = DEFAULT_CONFIG_DIR,
195
227
  ttlMs = RECALL_PULL_TTL_MS,
196
228
  timeoutMs = 15_000,
229
+ groupId,
197
230
  fetcher = globalThis.fetch,
231
+ warn = (s) => process.stderr.write(s),
198
232
  upsertPulledNoteFn = upsertPulledNote,
199
233
  rebuildIndexFn = rebuildIndex,
200
234
  deleteNoteFn = deleteNote,
@@ -209,20 +243,34 @@ export async function pullNotes({
209
243
  return { ok: true, skipped: true, count: 0 };
210
244
  }
211
245
 
212
- const url = new URL(`${apiBase()}${PULL_PATH}`);
213
- if (lastPulledAt) url.searchParams.set('since', lastPulledAt);
246
+ const buildUrl = (targetGroupId) => {
247
+ const url = new URL(`${apiBase()}${PULL_PATH}`);
248
+ if (lastPulledAt) url.searchParams.set('since', lastPulledAt);
249
+ if (targetGroupId !== undefined) url.searchParams.set('group_id', targetGroupId);
250
+ return url.toString();
251
+ };
252
+ const get = (targetGroupId) => fetcher(buildUrl(targetGroupId), {
253
+ headers: { 'Authorization': `Bearer ${cliToken}`, 'Accept': 'application/json' },
254
+ redirect: 'manual',
255
+ signal: AbortSignal.timeout(timeoutMs),
256
+ });
214
257
 
215
258
  let res;
216
259
  try {
217
- res = await fetcher(url.toString(), {
218
- headers: { 'Authorization': `Bearer ${cliToken}`, 'Accept': 'application/json' },
219
- redirect: 'manual',
220
- signal: AbortSignal.timeout(timeoutMs),
221
- });
260
+ res = await get(groupId);
222
261
  } catch {
223
262
  return { ok: false, count: 0 };
224
263
  }
225
264
 
265
+ if (!res.ok && res.status === 422 && groupId !== undefined && await isUnknownTeamRejection(res)) {
266
+ warnUnknownTeam(warn, groupId, 'pulling from your default team');
267
+ try {
268
+ res = await get(undefined);
269
+ } catch {
270
+ return { ok: false, count: 0 };
271
+ }
272
+ }
273
+
226
274
  if (!res.ok) {
227
275
  return { ok: false, status: res.status, count: 0 };
228
276
  }
@@ -10,11 +10,13 @@ import {
10
10
  loadProfiles,
11
11
  loadCredentials,
12
12
  saveProfile,
13
+ saveTeams,
14
+ loadTeams,
13
15
  invalidateProfilesCache,
14
16
  } from './profile-resolver.mjs';
15
17
  import { DEFAULT_CONFIG_DIR } from './config.mjs';
16
18
  import { apiBase } from './api-utils.mjs';
17
- import { createStyler } from './ansi.mjs';
19
+ import { createStyler, sanitizeUntrustedText } from './ansi.mjs';
18
20
 
19
21
  export const getApiBase = () => apiBase();
20
22
  // Strip the api. subdomain to get the console base URL (e.g. api.ticketlens.app → ticketlens.app)
@@ -89,6 +91,7 @@ export async function syncProfiles({
89
91
  try { json = await res.json(); } catch { return { error: 'invalid-json' }; }
90
92
 
91
93
  if (typeof json?.tier === 'string') saveCliTokenTier(json.tier, configDir);
94
+ saveTeams(Array.isArray(json?.teams) ? json.teams : [], configDir);
92
95
 
93
96
  const remoteProfiles = Array.isArray(json?.profiles) ? json.profiles : [];
94
97
  const localConfig = loadProfiles(configDir) || { profiles: {} };
@@ -125,6 +128,69 @@ export async function syncProfiles({
125
128
  return { added, updated, unchanged, needsCredentials };
126
129
  }
127
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
+
128
194
  /**
129
195
  * Formats a syncProfiles() result to a stream. Shared by `ticketlens sync`
130
196
  * and the onboarding hub's "Console login" step so both report the same