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.
- package/bin/ticketlens.mjs +24 -1
- 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/help.mjs +8 -0
- package/skills/jtb/scripts/lib/login-flow.mjs +8 -3
- package/skills/jtb/scripts/lib/note-command.mjs +6 -0
- package/skills/jtb/scripts/lib/profile-resolver.mjs +49 -2
- package/skills/jtb/scripts/lib/profile-set-team.mjs +69 -0
- package/skills/jtb/scripts/lib/recall-command.mjs +9 -2
- package/skills/jtb/scripts/lib/recall-sync.mjs +70 -22
- package/skills/jtb/scripts/lib/sync.mjs +67 -1
package/bin/ticketlens.mjs
CHANGED
|
@@ -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
|
@@ -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
|
|
|
@@ -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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
213
|
-
|
|
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
|
|
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
|