ticketlens 0.38.13 → 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.
- package/bin/ticketlens.mjs +14 -5
- package/package.json +1 -1
- package/skills/jtb/scripts/lib/ansi.mjs +15 -1
- package/skills/jtb/scripts/lib/config-wizard.mjs +8 -1
- package/skills/jtb/scripts/lib/note-command.mjs +4 -2
- package/skills/jtb/scripts/lib/profile-resolver.mjs +37 -5
- package/skills/jtb/scripts/lib/profile-set-team.mjs +43 -15
- package/skills/jtb/scripts/lib/recall-command.mjs +4 -2
- package/skills/jtb/scripts/lib/recall-sync.mjs +17 -16
- package/skills/jtb/scripts/lib/sync.mjs +65 -1
package/bin/ticketlens.mjs
CHANGED
|
@@ -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
|
|
369
|
-
process.stderr.write('Usage: ticketlens profiles set-team <profile>
|
|
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
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
|
@@ -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 (
|
|
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
|
|
59
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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,
|
|
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
|
-
|
|
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.
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
83
|
-
// (
|
|
84
|
-
// pull results; both retry once against the
|
|
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,
|
|
92
|
-
warn(` ${yellow('⚠')}
|
|
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.
|
|
186
|
-
warnUnknownTeam(warn, note.
|
|
187
|
-
const {
|
|
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 {
|
|
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
|
-
|
|
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 = (
|
|
246
|
+
const buildUrl = (targetGroupId) => {
|
|
246
247
|
const url = new URL(`${apiBase()}${PULL_PATH}`);
|
|
247
248
|
if (lastPulledAt) url.searchParams.set('since', lastPulledAt);
|
|
248
|
-
if (
|
|
249
|
+
if (targetGroupId !== undefined) url.searchParams.set('group_id', targetGroupId);
|
|
249
250
|
return url.toString();
|
|
250
251
|
};
|
|
251
|
-
const get = (
|
|
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(
|
|
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 &&
|
|
265
|
-
warnUnknownTeam(warn,
|
|
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
|