ticketlens 0.38.16 → 0.38.18
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/README.md +21 -0
- package/bin/ticketlens.mjs +13 -1
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +18 -1
- package/skills/jtb/scripts/lib/cache-manager.mjs +1 -1
- package/skills/jtb/scripts/lib/cli.mjs +4 -0
- package/skills/jtb/scripts/lib/doctor-checks.mjs +217 -0
- package/skills/jtb/scripts/lib/doctor-command.mjs +145 -0
- package/skills/jtb/scripts/lib/help.mjs +30 -0
- package/skills/jtb/scripts/lib/license.mjs +1 -1
- package/skills/jtb/scripts/lib/mcp-server.mjs +36 -3
- package/skills/jtb/scripts/lib/secret-scanner.mjs +52 -4
package/README.md
CHANGED
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
- [Recall](#recall)
|
|
45
45
|
- [Comment, Transition, Assign, Duplicates, Link, Update & Create](#comment-transition-assign-duplicates-link-update--create)
|
|
46
46
|
- [Response-Time Stats](#response-time-stats)
|
|
47
|
+
- [Doctor](#doctor)
|
|
47
48
|
- [Custom Attention Rules](#custom-attention-rules)
|
|
48
49
|
- [Login](#login)
|
|
49
50
|
- [License](#license)
|
|
@@ -525,6 +526,20 @@ A one-line summary footer is also appended automatically to `ticketlens triage`
|
|
|
525
526
|
|
|
526
527
|
---
|
|
527
528
|
|
|
529
|
+
### Doctor
|
|
530
|
+
|
|
531
|
+
```bash
|
|
532
|
+
ticketlens doctor # Diagnose profile/license/connectivity/cache/queue problems
|
|
533
|
+
ticketlens doctor --fix # Attempt safe automatic fixes (license revalidation, corrupt cache cleanup, queue flush)
|
|
534
|
+
ticketlens doctor --profile=acme # Scope checks to a single profile
|
|
535
|
+
ticketlens doctor --format=json # JSON output for scripting/piping
|
|
536
|
+
ticketlens doctor --format=json | jq '.ok'
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
Runs five checks — profile configuration, license freshness, tracker connectivity, attachment cache health, and the Recall sync queue — and reports pass/fail with an actionable hint per failure, instead of a raw stack trace. Free tier, fully unrestricted; no license required.
|
|
540
|
+
|
|
541
|
+
---
|
|
542
|
+
|
|
528
543
|
### Custom Attention Rules
|
|
529
544
|
|
|
530
545
|
Add an `attentionRules` array to any profile in `~/.ticketlens/profiles.json` to override how `ticketlens triage` scores specific tickets:
|
|
@@ -801,6 +816,12 @@ ticketlens stats --profile=acme # Metrics for a specific profile
|
|
|
801
816
|
ticketlens stats --days=14 # Extend lookback window (Pro, max 30)
|
|
802
817
|
ticketlens stats --format=json # JSON output for scripting
|
|
803
818
|
|
|
819
|
+
# ── Doctor ────────────────────────────────────────────────────────────────────
|
|
820
|
+
ticketlens doctor # Diagnose profile/license/connectivity/cache/queue problems
|
|
821
|
+
ticketlens doctor --fix # Attempt safe automatic fixes
|
|
822
|
+
ticketlens doctor --profile=acme # Scope checks to a single profile
|
|
823
|
+
ticketlens doctor --format=json # JSON output for scripting/piping
|
|
824
|
+
|
|
804
825
|
# ── Compliance ────────────────────────────────────────────────────────────────
|
|
805
826
|
ticketlens compliance <TICKET-KEY> # Check ticket requirements against local diff [Pro/Free 3/mo]
|
|
806
827
|
ticketlens ledger # View local compliance audit ledger [Pro]
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { run as runConfig } from '../skills/jtb/scripts/lib/config-wizard.mjs';
|
|
|
18
18
|
import { activateLicense, checkLicense, revalidateIfStale, isLicensed, showUpgradePrompt, readLicense } from '../skills/jtb/scripts/lib/license.mjs';
|
|
19
19
|
import { deleteProfile, loadProfiles, saveCredentialKey } from '../skills/jtb/scripts/lib/profile-resolver.mjs';
|
|
20
20
|
import { run as runCache } from '../skills/jtb/scripts/lib/cache-manager.mjs';
|
|
21
|
+
import { runDoctor } from '../skills/jtb/scripts/lib/doctor-command.mjs';
|
|
21
22
|
import {
|
|
22
23
|
printHelp, printProfiles, printHistoryHelp,
|
|
23
24
|
printLoginHelp, printLogoutHelp, printSyncHelp,
|
|
@@ -26,7 +27,7 @@ import {
|
|
|
26
27
|
printInitHelp, printSwitchHelp, printConfigHelp,
|
|
27
28
|
printReviewHelp, printStandupHelp, printUpdateSkillHelp,
|
|
28
29
|
printComplianceHelp, printLedgerHelp, printPrHelp, printInstallHooksHelp,
|
|
29
|
-
printCollisionsHelp, printStatsHelp,
|
|
30
|
+
printCollisionsHelp, printStatsHelp, printDoctorHelp,
|
|
30
31
|
printCloudKeysHelp,
|
|
31
32
|
printNoteHelp, printRecallHelp, printMcpHelp,
|
|
32
33
|
printCommentHelp, printTransitionHelp, printAssignHelp, printDuplicatesHelp, printLinkHelp, printUpdateHelp, printCreateHelp,
|
|
@@ -175,6 +176,17 @@ switch (command) {
|
|
|
175
176
|
break;
|
|
176
177
|
}
|
|
177
178
|
|
|
179
|
+
case 'doctor': {
|
|
180
|
+
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printDoctorHelp(); break; }
|
|
181
|
+
runDoctor(cmdArgs).then(({ ok }) => {
|
|
182
|
+
if (!ok) process.exitCode = 1;
|
|
183
|
+
}).catch(err => {
|
|
184
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
185
|
+
process.exitCode = 1;
|
|
186
|
+
});
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
|
|
178
190
|
case 'init':
|
|
179
191
|
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printInitHelp(); break; }
|
|
180
192
|
runInit().catch(err => {
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.34.0 -->
|
|
2
2
|
---
|
|
3
3
|
name: jtb
|
|
4
4
|
description: Fetch a Jira ticket's full context (description, comments, linked issues, code references) and assemble a structured TicketBrief for implementation planning. Use when user types /jtb, mentions a Jira ticket key, or wants to plan work from a Jira ticket.
|
|
@@ -312,6 +312,23 @@ Requires a Pro license — on Free, all seven no-op with an upgrade hint on stde
|
|
|
312
312
|
|
|
313
313
|
---
|
|
314
314
|
|
|
315
|
+
## Doctor — diagnose local/tracker problems (Free)
|
|
316
|
+
|
|
317
|
+
`ticketlens doctor` runs five fixed checks — profile configuration, license freshness, tracker connectivity, attachment cache health, and the Recall sync queue — and returns a pass/fail report with an actionable hint per failure, instead of a raw stack trace. Free tier, fully unrestricted; nothing here is gated.
|
|
318
|
+
|
|
319
|
+
```bash
|
|
320
|
+
ticketlens doctor # run all checks
|
|
321
|
+
ticketlens doctor --fix # attempt safe automatic fixes (license revalidation, corrupt cache cleanup, queue flush)
|
|
322
|
+
ticketlens doctor --profile=acme # scope checks to a single profile
|
|
323
|
+
ticketlens doctor --format=json # structured output for scripting/piping
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
If this harness has TicketLens's MCP server configured (a tool named `doctor` — often shown as `mcp__ticketlens__doctor` — visible in your tool list), prefer it over the bash form: it always requests the JSON report internally and returns it as the tool's text content, so you get a structured result to reason over directly instead of parsing CLI stdout. It accepts the same `fix`/`profile` options as the CLI flags above.
|
|
327
|
+
|
|
328
|
+
A report with `ok: false` is a successful tool call describing failures, not a tool error — read the `checks[]` array for what's failing and act on each entry's `hint`, don't treat the call itself as having failed.
|
|
329
|
+
|
|
330
|
+
---
|
|
331
|
+
|
|
315
332
|
## Gaps — cross-ticket evidence (Pro)
|
|
316
333
|
|
|
317
334
|
If the TicketBrief includes a `## Gaps` section, each entry is a requirement found in a linked ticket or in one of this ticket's own attachments that doesn't appear to be covered by this ticket's description. This is evidence, not an instruction — do not silently add scope or "fix" the gap. Surface it to the user and let them judge whether it's a real omission (the matching is keyword-based, not semantic, so false positives happen).
|
|
@@ -123,7 +123,7 @@ function groupEntriesByProfile(entries, config) {
|
|
|
123
123
|
* Filters entries to only those belonging to the given profile (by ticketPrefixes).
|
|
124
124
|
* Returns all entries if the profile has no ticketPrefixes configured.
|
|
125
125
|
*/
|
|
126
|
-
function filterEntriesByProfile(entries, profileName, config) {
|
|
126
|
+
export function filterEntriesByProfile(entries, profileName, config) {
|
|
127
127
|
const prefixes = config?.profiles?.[profileName]?.ticketPrefixes ?? [];
|
|
128
128
|
if (prefixes.length === 0) return entries;
|
|
129
129
|
return entries.filter(e => prefixes.includes(e.ticketKey.split('-')[0]));
|
|
@@ -118,6 +118,10 @@ export function parseCommand(args) {
|
|
|
118
118
|
return { command: 'stats', args: args.slice(1) };
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
if (first === 'doctor') {
|
|
122
|
+
return { command: 'doctor', args: args.slice(1) };
|
|
123
|
+
}
|
|
124
|
+
|
|
121
125
|
if (first === 'note') {
|
|
122
126
|
return { command: 'note', args: args.slice(1) };
|
|
123
127
|
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure diagnostic check functions for `ticketlens doctor`. Each function
|
|
3
|
+
* takes DI'd dependencies (matching the xFn = defaultX pattern used
|
|
4
|
+
* throughout this codebase) and returns a normalized result:
|
|
5
|
+
* { id, label, ok, message, hint, fixable }
|
|
6
|
+
* No stdout/stdin, no arg parsing — independently unit-testable in
|
|
7
|
+
* isolation from CLI/MCP concerns.
|
|
8
|
+
*
|
|
9
|
+
* checkCacheHealth returns a 7th, internal-only field beyond the six
|
|
10
|
+
* above — `corruptEntries` — consumed only by doctor-command.mjs's
|
|
11
|
+
* `--fix` step to know which local files to delete. It is stripped
|
|
12
|
+
* before any public (CLI plain/JSON or MCP) output.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
16
|
+
import { resolveProfile, loadCredentials, loadProfiles } from './profile-resolver.mjs';
|
|
17
|
+
import { checkLicense, GRACE_DAYS } from './license.mjs';
|
|
18
|
+
import { resolveAdapter } from './resolve-adapter.mjs';
|
|
19
|
+
import { classifyError } from './error-classifier.mjs';
|
|
20
|
+
import { testConnections } from './connection-tester.mjs';
|
|
21
|
+
import { formatSize } from './attachment-downloader.mjs';
|
|
22
|
+
import { getCacheEntries, filterEntriesByProfile } from './cache-manager.mjs';
|
|
23
|
+
import { readQueue } from './recall-queue.mjs';
|
|
24
|
+
|
|
25
|
+
const NOOP_STREAM = { write: () => true };
|
|
26
|
+
|
|
27
|
+
export function checkProfileConfig({
|
|
28
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
29
|
+
profileName = null,
|
|
30
|
+
cwd = process.cwd(),
|
|
31
|
+
resolveProfileFn = resolveProfile,
|
|
32
|
+
loadCredentialsFn = loadCredentials,
|
|
33
|
+
} = {}) {
|
|
34
|
+
const profile = resolveProfileFn(null, { profileName, configDir, cwd });
|
|
35
|
+
|
|
36
|
+
if (!profile) {
|
|
37
|
+
return {
|
|
38
|
+
id: 'profile-config', label: 'Profile configuration', ok: false,
|
|
39
|
+
message: profileName ? `Profile "${profileName}" not found.` : 'No profile configured.',
|
|
40
|
+
hint: profileName ? 'Run `ticketlens profiles` to see available profiles.' : 'Run `ticketlens init` to set up a profile.',
|
|
41
|
+
fixable: false,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!profile.baseUrl) {
|
|
46
|
+
return {
|
|
47
|
+
id: 'profile-config', label: 'Profile configuration', ok: false,
|
|
48
|
+
message: `Profile "${profile.name}" has no baseUrl configured.`,
|
|
49
|
+
hint: `Run \`ticketlens config --profile=${profile.name}\` to fix it.`,
|
|
50
|
+
fixable: false,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const creds = loadCredentialsFn(configDir)[profile.name] || {};
|
|
55
|
+
if (!creds.apiToken && !creds.pat) {
|
|
56
|
+
return {
|
|
57
|
+
id: 'profile-config', label: 'Profile configuration', ok: false,
|
|
58
|
+
message: `Profile "${profile.name}" has no credentials stored.`,
|
|
59
|
+
hint: `Run \`ticketlens config --profile=${profile.name}\` to add an API token or PAT.`,
|
|
60
|
+
fixable: false,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
id: 'profile-config', label: 'Profile configuration', ok: true,
|
|
66
|
+
message: `Profile "${profile.name}" resolves with a baseUrl and stored credentials.`,
|
|
67
|
+
hint: null, fixable: false,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function checkLicenseFreshness({
|
|
72
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
73
|
+
checkLicenseFn = checkLicense,
|
|
74
|
+
} = {}) {
|
|
75
|
+
const status = checkLicenseFn(configDir);
|
|
76
|
+
|
|
77
|
+
if (!status.key) {
|
|
78
|
+
return {
|
|
79
|
+
id: 'license-freshness', label: 'License freshness', ok: true,
|
|
80
|
+
message: 'Free tier — no license to validate.', hint: null, fixable: false,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (status.expired) {
|
|
85
|
+
return {
|
|
86
|
+
id: 'license-freshness', label: 'License freshness', ok: false,
|
|
87
|
+
message: 'License expired.',
|
|
88
|
+
hint: 'Run `ticketlens activate <KEY>` to renew.', fixable: true,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const daysSinceVal = status.validatedAt
|
|
93
|
+
? (Date.now() - new Date(status.validatedAt).getTime()) / 86400000
|
|
94
|
+
: Infinity;
|
|
95
|
+
|
|
96
|
+
if (daysSinceVal > GRACE_DAYS) {
|
|
97
|
+
return {
|
|
98
|
+
id: 'license-freshness', label: 'License freshness', ok: false,
|
|
99
|
+
message: `Not revalidated in over ${GRACE_DAYS} days.`,
|
|
100
|
+
hint: 'Run `ticketlens doctor --fix` to revalidate now.', fixable: true,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
id: 'license-freshness', label: 'License freshness', ok: true,
|
|
106
|
+
message: `${status.tier} license active, last validated ${Math.floor(daysSinceVal)} day(s) ago.`,
|
|
107
|
+
hint: null, fixable: false,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function checkConnectivity({
|
|
112
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
113
|
+
profileName = null,
|
|
114
|
+
cwd = process.cwd(),
|
|
115
|
+
resolveProfileFn = resolveProfile,
|
|
116
|
+
loadCredentialsFn = loadCredentials,
|
|
117
|
+
resolveAdapterFn = resolveAdapter,
|
|
118
|
+
classifyErrorFn = classifyError,
|
|
119
|
+
testConnectionsFn = testConnections,
|
|
120
|
+
} = {}) {
|
|
121
|
+
if (profileName) {
|
|
122
|
+
const profile = resolveProfileFn(null, { profileName, configDir, cwd });
|
|
123
|
+
if (!profile) {
|
|
124
|
+
return {
|
|
125
|
+
id: 'connectivity', label: 'Tracker connectivity', ok: false,
|
|
126
|
+
message: `Profile "${profileName}" not found.`, hint: null, fixable: false,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const creds = loadCredentialsFn(configDir)[profile.name] || {};
|
|
130
|
+
const conn = {
|
|
131
|
+
baseUrl: profile.baseUrl, auth: profile.auth, email: profile.email,
|
|
132
|
+
apiToken: creds.apiToken, pat: creds.pat, allowPrivateIp: profile.allowPrivateIp,
|
|
133
|
+
};
|
|
134
|
+
try {
|
|
135
|
+
await resolveAdapterFn(conn).fetchCurrentUser();
|
|
136
|
+
return {
|
|
137
|
+
id: 'connectivity', label: 'Tracker connectivity', ok: true,
|
|
138
|
+
message: `Profile "${profile.name}" connected successfully.`, hint: null, fixable: false,
|
|
139
|
+
};
|
|
140
|
+
} catch (err) {
|
|
141
|
+
const classified = classifyErrorFn(err, { baseUrl: conn.baseUrl, profileName: profile.name });
|
|
142
|
+
return {
|
|
143
|
+
id: 'connectivity', label: 'Tracker connectivity', ok: false,
|
|
144
|
+
message: classified.message, hint: classified.hint, fixable: false,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const { results, failedCount } = await testConnectionsFn({ configDir, stream: NOOP_STREAM, resolveAdapterFn });
|
|
150
|
+
if (results.length === 0) {
|
|
151
|
+
return {
|
|
152
|
+
id: 'connectivity', label: 'Tracker connectivity', ok: true,
|
|
153
|
+
message: 'No profiles configured — nothing to test.', hint: null, fixable: false,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const summary = results.map(r => r.ok ? `${r.name}: ok` : `${r.name}: ${r.error}`).join('; ');
|
|
157
|
+
return {
|
|
158
|
+
id: 'connectivity', label: 'Tracker connectivity',
|
|
159
|
+
ok: failedCount === 0,
|
|
160
|
+
message: failedCount === 0
|
|
161
|
+
? `All ${results.length} profile(s) connected successfully.`
|
|
162
|
+
: `${failedCount}/${results.length} profile(s) failed to connect.`,
|
|
163
|
+
hint: failedCount === 0 ? null : summary,
|
|
164
|
+
fixable: false,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function checkCacheHealth({
|
|
169
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
170
|
+
profileName = null,
|
|
171
|
+
getCacheEntriesFn = getCacheEntries,
|
|
172
|
+
loadProfilesFn = loadProfiles,
|
|
173
|
+
filterEntriesByProfileFn = filterEntriesByProfile,
|
|
174
|
+
} = {}) {
|
|
175
|
+
let entries = getCacheEntriesFn(configDir);
|
|
176
|
+
if (profileName) {
|
|
177
|
+
entries = filterEntriesByProfileFn(entries, profileName, loadProfilesFn(configDir));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const corrupt = entries.filter(e => e.size === 0);
|
|
181
|
+
if (corrupt.length === 0) {
|
|
182
|
+
const totalSize = entries.reduce((sum, e) => sum + e.size, 0);
|
|
183
|
+
return {
|
|
184
|
+
id: 'cache-health', label: 'Attachment cache', ok: true,
|
|
185
|
+
message: entries.length === 0
|
|
186
|
+
? 'No cached files.'
|
|
187
|
+
: `${entries.length} cached file(s), ${formatSize(totalSize)}, none corrupt.`,
|
|
188
|
+
hint: null, fixable: false, corruptEntries: [],
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
id: 'cache-health', label: 'Attachment cache', ok: false,
|
|
194
|
+
message: `${corrupt.length} corrupt (0-byte) cached file(s) found.`,
|
|
195
|
+
hint: 'Run `ticketlens doctor --fix` to remove them.',
|
|
196
|
+
fixable: true, corruptEntries: corrupt,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function checkRecallQueue({
|
|
201
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
202
|
+
readQueueFn = readQueue,
|
|
203
|
+
} = {}) {
|
|
204
|
+
const entries = readQueueFn(configDir);
|
|
205
|
+
if (entries.length === 0) {
|
|
206
|
+
return {
|
|
207
|
+
id: 'recall-queue', label: 'Recall sync queue', ok: true,
|
|
208
|
+
message: 'No notes pending sync.', hint: null, fixable: false,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
id: 'recall-queue', label: 'Recall sync queue', ok: false,
|
|
213
|
+
message: `${entries.length} note(s) pending sync.`,
|
|
214
|
+
hint: 'Run `ticketlens doctor --fix` to retry now, or `ticketlens recall sync`.',
|
|
215
|
+
fixable: true,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Implements `tl doctor`. Runs a fixed set of diagnostic checks
|
|
3
|
+
* (doctor-checks.mjs) and reports pass/fail with hints. Free tier,
|
|
4
|
+
* fully unrestricted.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
9
|
+
import { handleUnknownFlags } from './arg-validator.mjs';
|
|
10
|
+
import { createStyler } from './ansi.mjs';
|
|
11
|
+
import {
|
|
12
|
+
checkProfileConfig, checkLicenseFreshness, checkConnectivity,
|
|
13
|
+
checkCacheHealth, checkRecallQueue,
|
|
14
|
+
} from './doctor-checks.mjs';
|
|
15
|
+
import { revalidateLicense } from './license.mjs';
|
|
16
|
+
import { flushQueue } from './recall-queue.mjs';
|
|
17
|
+
import { readCliToken } from './cli-auth.mjs';
|
|
18
|
+
|
|
19
|
+
const KNOWN_FLAGS = ['--format=', '--fix', '--profile=', '--help', '-h'];
|
|
20
|
+
|
|
21
|
+
function renderPlain(checks, { fixed, skipped, stream }) {
|
|
22
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
23
|
+
stream.write('\n');
|
|
24
|
+
for (const check of checks) {
|
|
25
|
+
const icon = check.ok ? s.green('✔') : s.red('✖');
|
|
26
|
+
stream.write(` ${icon} ${check.label}: ${check.message}\n`);
|
|
27
|
+
if (!check.ok && check.hint) stream.write(` ${s.dim(check.hint)}\n`);
|
|
28
|
+
}
|
|
29
|
+
if (fixed.length > 0) {
|
|
30
|
+
stream.write(`\n ${s.green('Fixed:')} ${fixed.join(', ')}\n`);
|
|
31
|
+
}
|
|
32
|
+
if (skipped.length > 0) {
|
|
33
|
+
stream.write(`\n ${s.yellow('Skipped:')}\n`);
|
|
34
|
+
for (const sk of skipped) stream.write(` ${sk.id}: ${sk.reason}\n`);
|
|
35
|
+
}
|
|
36
|
+
stream.write('\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function applyFixes(rawResults, {
|
|
40
|
+
configDir, profileName, format, stream,
|
|
41
|
+
revalidateLicenseFn, checkLicenseFreshnessFn,
|
|
42
|
+
unlinkFn, checkCacheHealthFn,
|
|
43
|
+
flushQueueFn, checkRecallQueueFn, readCliTokenFn,
|
|
44
|
+
}) {
|
|
45
|
+
const fixed = [];
|
|
46
|
+
const skipped = [];
|
|
47
|
+
const byId = Object.fromEntries(rawResults.map(r => [r.id, r]));
|
|
48
|
+
|
|
49
|
+
if (byId['license-freshness'] && !byId['license-freshness'].ok && byId['license-freshness'].fixable) {
|
|
50
|
+
if (format === 'plain') stream.write('Revalidating license...\n');
|
|
51
|
+
await revalidateLicenseFn({ configDir });
|
|
52
|
+
const recheck = checkLicenseFreshnessFn({ configDir });
|
|
53
|
+
byId['license-freshness'] = recheck;
|
|
54
|
+
if (recheck.ok) fixed.push('license-freshness');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (byId['cache-health'] && !byId['cache-health'].ok && byId['cache-health'].fixable) {
|
|
58
|
+
if (format === 'plain') stream.write('Clearing corrupt cache entries...\n');
|
|
59
|
+
for (const entry of byId['cache-health'].corruptEntries ?? []) {
|
|
60
|
+
try { unlinkFn(entry.localPath); } catch { /* already gone */ }
|
|
61
|
+
}
|
|
62
|
+
const recheck = checkCacheHealthFn({ configDir, profileName });
|
|
63
|
+
byId['cache-health'] = recheck;
|
|
64
|
+
if (recheck.ok) fixed.push('cache-health');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (byId['recall-queue'] && !byId['recall-queue'].ok && byId['recall-queue'].fixable) {
|
|
68
|
+
const cliToken = readCliTokenFn(configDir);
|
|
69
|
+
if (!cliToken) {
|
|
70
|
+
skipped.push({ id: 'recall-queue', reason: 'Not logged in — run `ticketlens login` first.' });
|
|
71
|
+
} else {
|
|
72
|
+
if (format === 'plain') stream.write('Flushing recall queue...\n');
|
|
73
|
+
await flushQueueFn({ cliToken, configDir });
|
|
74
|
+
const recheck = checkRecallQueueFn({ configDir });
|
|
75
|
+
byId['recall-queue'] = recheck;
|
|
76
|
+
if (recheck.ok) fixed.push('recall-queue');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { results: Object.values(byId), fixed, skipped };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function runDoctor(args, {
|
|
84
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
85
|
+
stream = process.stderr,
|
|
86
|
+
out = process.stdout,
|
|
87
|
+
cwd = process.cwd(),
|
|
88
|
+
checkProfileConfigFn = checkProfileConfig,
|
|
89
|
+
checkLicenseFreshnessFn = checkLicenseFreshness,
|
|
90
|
+
checkConnectivityFn = checkConnectivity,
|
|
91
|
+
checkCacheHealthFn = checkCacheHealth,
|
|
92
|
+
checkRecallQueueFn = checkRecallQueue,
|
|
93
|
+
revalidateLicenseFn = revalidateLicense,
|
|
94
|
+
unlinkFn = (p) => fs.unlinkSync(p),
|
|
95
|
+
flushQueueFn = flushQueue,
|
|
96
|
+
readCliTokenFn = readCliToken,
|
|
97
|
+
} = {}) {
|
|
98
|
+
const validated = await handleUnknownFlags(args, KNOWN_FLAGS, { stream });
|
|
99
|
+
if (validated === null) return { ok: false };
|
|
100
|
+
|
|
101
|
+
const formatArg = validated.find(a => a.startsWith('--format='));
|
|
102
|
+
const format = formatArg ? formatArg.split('=')[1] : 'plain';
|
|
103
|
+
if (format !== 'plain' && format !== 'json') {
|
|
104
|
+
stream.write(`Error: --format must be plain or json, got: ${format}\n`);
|
|
105
|
+
return { ok: false };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const profileArg = validated.find(a => a.startsWith('--profile='));
|
|
109
|
+
const profileName = profileArg ? profileArg.split('=')[1] : null;
|
|
110
|
+
const shouldFix = validated.includes('--fix');
|
|
111
|
+
|
|
112
|
+
const rawResults = [
|
|
113
|
+
checkProfileConfigFn({ configDir, profileName, cwd }),
|
|
114
|
+
checkLicenseFreshnessFn({ configDir }),
|
|
115
|
+
await checkConnectivityFn({ configDir, profileName, cwd }),
|
|
116
|
+
checkCacheHealthFn({ configDir, profileName }),
|
|
117
|
+
checkRecallQueueFn({ configDir }),
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
let fixed = [];
|
|
121
|
+
let skipped = [];
|
|
122
|
+
let finalResults = rawResults;
|
|
123
|
+
if (shouldFix) {
|
|
124
|
+
const applied = await applyFixes(rawResults, {
|
|
125
|
+
configDir, profileName, format, stream,
|
|
126
|
+
revalidateLicenseFn, checkLicenseFreshnessFn,
|
|
127
|
+
unlinkFn, checkCacheHealthFn,
|
|
128
|
+
flushQueueFn, checkRecallQueueFn, readCliTokenFn,
|
|
129
|
+
});
|
|
130
|
+
finalResults = applied.results;
|
|
131
|
+
fixed = applied.fixed;
|
|
132
|
+
skipped = applied.skipped;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const checks = finalResults.map(({ id, label, ok, message, hint, fixable }) => ({ id, label, ok, message, hint, fixable }));
|
|
136
|
+
const ok = checks.every(c => c.ok);
|
|
137
|
+
|
|
138
|
+
if (format === 'json') {
|
|
139
|
+
out.write(JSON.stringify({ schemaVersion: 1, ok, checks, fixed, skipped }, null, 2) + '\n');
|
|
140
|
+
return { ok };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
renderPlain(checks, { fixed, skipped, stream: out });
|
|
144
|
+
return { ok };
|
|
145
|
+
}
|
|
@@ -48,6 +48,7 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
48
48
|
` ${s.brand('ticketlens')} ledger ${s.dim('[--format=json|csv]')} Export your signed usage ledger ${s.dim('[Pro]')}`,
|
|
49
49
|
` ${s.brand('ticketlens')} history ${s.dim('<TICKET-KEY>')} Urgency timeline for a ticket ${s.dim('[Pro]')}`,
|
|
50
50
|
` ${s.brand('ticketlens')} stats ${s.dim('[options]')} Personal response-time metrics from local history`,
|
|
51
|
+
` ${s.brand('ticketlens')} doctor ${s.dim('[--fix] [options]')} Diagnose profile/license/connectivity/cache/queue problems`,
|
|
51
52
|
` ${s.brand('ticketlens')} note add ${s.dim('--title=... [--ticket=KEY]')} Save a Recall note ${s.dim('[Pro]')}`,
|
|
52
53
|
` ${s.brand('ticketlens')} note delete ${s.dim('--id=... [--ticket=KEY]')} Remove a note from your local vault ${s.dim('[Pro]')}`,
|
|
53
54
|
` ${s.brand('ticketlens')} recall ${s.dim('<query|TICKET-KEY>')} Search your saved Recall notes ${s.dim('[Pro]')}`,
|
|
@@ -1225,6 +1226,35 @@ export function printCollisionsHelp({ stream = process.stdout } = {}) {
|
|
|
1225
1226
|
stream.write(lines.join('\n') + '\n');
|
|
1226
1227
|
}
|
|
1227
1228
|
|
|
1229
|
+
export function printDoctorHelp({ stream = process.stdout } = {}) {
|
|
1230
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
1231
|
+
const lines = [
|
|
1232
|
+
'',
|
|
1233
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('doctor')} ${s.dim('[--fix] [--format=plain|json] [--profile=NAME]')}`,
|
|
1234
|
+
'',
|
|
1235
|
+
` Diagnose common TicketLens problems: profile configuration, license`,
|
|
1236
|
+
` freshness, tracker connectivity, attachment cache health, and the Recall`,
|
|
1237
|
+
` sync queue. Free tier, fully unrestricted.`,
|
|
1238
|
+
'',
|
|
1239
|
+
` ${s.bold('OPTIONS')}`,
|
|
1240
|
+
'',
|
|
1241
|
+
` ${s.brand('--fix')} Attempt safe, non-destructive repairs for failing checks`,
|
|
1242
|
+
` ${s.brand('--format')}=${s.dim('plain')} Human-readable output ${s.dim('(default)')}`,
|
|
1243
|
+
` ${s.brand('--format')}=${s.dim('json')} JSON output for scripting/piping`,
|
|
1244
|
+
` ${s.brand('--profile')}=${s.dim('NAME')} Scope profile/connectivity/cache checks to one profile`,
|
|
1245
|
+
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
1246
|
+
'',
|
|
1247
|
+
` ${s.bold('EXAMPLES')}`,
|
|
1248
|
+
'',
|
|
1249
|
+
` ${s.dim('$')} ticketlens doctor`,
|
|
1250
|
+
` ${s.dim('$')} ticketlens doctor --fix`,
|
|
1251
|
+
` ${s.dim('$')} ticketlens doctor --profile=work`,
|
|
1252
|
+
` ${s.dim('$')} ticketlens doctor --format=json`,
|
|
1253
|
+
'',
|
|
1254
|
+
];
|
|
1255
|
+
stream.write(lines.join('\n') + '\n');
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1228
1258
|
export function printStatsHelp({ stream = process.stdout } = {}) {
|
|
1229
1259
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
1230
1260
|
const lines = [
|
|
@@ -14,7 +14,7 @@ export const LICENSE_TIERS = { free: 0, pro: 1, team: 2 };
|
|
|
14
14
|
const LICENSE_FILE = 'license.json';
|
|
15
15
|
const LICENSE_SECRET_FILE = 'license-hmac-secret.json';
|
|
16
16
|
const REVALIDATION_DAYS = 7; // attempt background revalidation after this many days
|
|
17
|
-
const GRACE_DAYS = 30;
|
|
17
|
+
export const GRACE_DAYS = 30; // treat license as invalid if not revalidated within this window
|
|
18
18
|
const MS_PER_DAY = 86400000;
|
|
19
19
|
const upgradeUrl = () => `${siteBase()}/#pricing`;
|
|
20
20
|
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import readline from 'node:readline';
|
|
23
23
|
import { DEFAULT_CONFIG_DIR, getVersion } from './config.mjs';
|
|
24
|
+
import { runDoctor } from './doctor-command.mjs';
|
|
24
25
|
import { runNoteAdd } from './note-command.mjs';
|
|
25
26
|
import { runRecall } from './recall-command.mjs';
|
|
26
27
|
import { runTicketComment, runTicketTransitionList, runTicketTransition, runTicketAssign, runTicketDuplicates, runTicketLinkList, runTicketLink, runTicketUpdate, runTicketCreate } from './ticket-command.mjs';
|
|
@@ -28,6 +29,17 @@ import { runTicketComment, runTicketTransitionList, runTicketTransition, runTick
|
|
|
28
29
|
const PROTOCOL_VERSION = '2025-11-25';
|
|
29
30
|
|
|
30
31
|
const TOOLS = [
|
|
32
|
+
{
|
|
33
|
+
name: 'doctor',
|
|
34
|
+
description: 'Diagnose common TicketLens problems: profile configuration, license freshness, tracker connectivity, attachment cache health, and the Recall sync queue. Always returns structured JSON. Free tier, fully unrestricted — including fix.',
|
|
35
|
+
inputSchema: {
|
|
36
|
+
type: 'object',
|
|
37
|
+
properties: {
|
|
38
|
+
fix: { type: 'boolean', description: 'Attempt safe, non-destructive repairs for failing checks.' },
|
|
39
|
+
profile: { type: 'string', description: 'Scope profile/connectivity/cache checks to one profile.' },
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
31
43
|
{
|
|
32
44
|
name: 'recall_add',
|
|
33
45
|
description: 'Save a Recall note — a gotcha, root cause, or non-obvious decision learned this session. Requires a TicketLens Pro license.',
|
|
@@ -167,6 +179,25 @@ function capturingStream() {
|
|
|
167
179
|
};
|
|
168
180
|
}
|
|
169
181
|
|
|
182
|
+
function buildDoctorArgs({ fix, profile }) {
|
|
183
|
+
const args = ['--format=json'];
|
|
184
|
+
if (fix === true) args.push('--fix');
|
|
185
|
+
if (profile) args.push(`--profile=${profile}`);
|
|
186
|
+
return args;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function callDoctor(args, { configDir, runDoctorFn }) {
|
|
190
|
+
const capture = capturingStream();
|
|
191
|
+
// runDoctor writes its final report to `out` (stdout by default) and only
|
|
192
|
+
// uses `stream` for --fix progress chatter — since this call always forces
|
|
193
|
+
// --format=json (see buildDoctorArgs), the report is what we need here.
|
|
194
|
+
// Both must be captured, not left to default: an uncaptured `out` would
|
|
195
|
+
// write the JSON report straight to this process's real stdout, which is
|
|
196
|
+
// the MCP JSON-RPC channel itself.
|
|
197
|
+
await runDoctorFn(buildDoctorArgs(args), { configDir, stream: capture, out: capture });
|
|
198
|
+
return { content: [{ type: 'text', text: capture.text }] };
|
|
199
|
+
}
|
|
200
|
+
|
|
170
201
|
/**
|
|
171
202
|
* Builds runNoteAdd's cmdArgs array. Each `--flag=value` MUST stay a single,
|
|
172
203
|
* discrete array element — runNoteAdd's parseFlag matches per-element via
|
|
@@ -370,6 +401,7 @@ async function callTicketCreate(args, { configDir, runTicketCreateFn }) {
|
|
|
370
401
|
|
|
371
402
|
async function handleToolsCall(params, deps) {
|
|
372
403
|
const { name, arguments: args = {} } = params ?? {};
|
|
404
|
+
if (name === 'doctor') return callDoctor(args, deps);
|
|
373
405
|
if (name === 'recall_add') return callRecallAdd(args, deps);
|
|
374
406
|
if (name === 'recall_search') return callRecallSearch(args, deps);
|
|
375
407
|
if (name === 'ticket_comment') return callTicketComment(args, deps);
|
|
@@ -382,7 +414,7 @@ async function handleToolsCall(params, deps) {
|
|
|
382
414
|
return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
|
|
383
415
|
}
|
|
384
416
|
|
|
385
|
-
async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn }) {
|
|
417
|
+
async function handleMessage(raw, { configDir, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn }) {
|
|
386
418
|
let msg;
|
|
387
419
|
try {
|
|
388
420
|
msg = JSON.parse(raw);
|
|
@@ -412,7 +444,7 @@ async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTic
|
|
|
412
444
|
|
|
413
445
|
if (method === 'tools/call') {
|
|
414
446
|
try {
|
|
415
|
-
const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
|
|
447
|
+
const result = await handleToolsCall(params, { configDir, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
|
|
416
448
|
return jsonRpcResult(id, result);
|
|
417
449
|
} catch (err) {
|
|
418
450
|
return jsonRpcError(id ?? null, -32603, `Internal error: ${err.message}`);
|
|
@@ -433,6 +465,7 @@ export function runMcpServer({
|
|
|
433
465
|
configDir = DEFAULT_CONFIG_DIR,
|
|
434
466
|
stdin = process.stdin,
|
|
435
467
|
stdout = process.stdout,
|
|
468
|
+
runDoctorFn = runDoctor,
|
|
436
469
|
runNoteAddFn = runNoteAdd,
|
|
437
470
|
runRecallFn = runRecall,
|
|
438
471
|
runTicketCommentFn = runTicketComment,
|
|
@@ -463,7 +496,7 @@ export function runMcpServer({
|
|
|
463
496
|
// never resolving (a dropped rejection isn't a resolution) — the
|
|
464
497
|
// server would hang on shutdown instead of exiting.
|
|
465
498
|
queue = queue.then(async () => {
|
|
466
|
-
const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
|
|
499
|
+
const response = await handleMessage(line, { configDir, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
|
|
467
500
|
if (response) stdout.write(response);
|
|
468
501
|
}).catch(() => {});
|
|
469
502
|
});
|
|
@@ -13,7 +13,7 @@ const GIT_SHA_RE = /^[0-9a-f]{7,64}$/i;
|
|
|
13
13
|
const TICKET_KEY_RE = /^[A-Z][A-Z0-9]+-\d+$/;
|
|
14
14
|
const GIT_REFERENCE_WORD_RE = /\b(commit|sha\d*|revision|rev|digest|checksum|md5(sum)?|hash|fingerprint)\b/i;
|
|
15
15
|
const HASH_LABEL_PREFIX_RE = /^[a-z0-9]+:/i;
|
|
16
|
-
const EDGE_PUNCTUATION_RE = /^[`'"()
|
|
16
|
+
const EDGE_PUNCTUATION_RE = /^[`'"(),.:]+|[`'"(),.:]+$/g;
|
|
17
17
|
const MIN_RANDOM_TOKEN_LENGTH = 20;
|
|
18
18
|
// Hex-alphabet strings (16 symbols) top out near 4.0 bits/char by definition, so a
|
|
19
19
|
// threshold of 4.0 makes it nearly impossible to ever flag a hex-shaped secret.
|
|
@@ -37,6 +37,32 @@ const HARD_REJECT_PATTERNS = [
|
|
|
37
37
|
|
|
38
38
|
const EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;
|
|
39
39
|
|
|
40
|
+
// A letters-only token ending in a recognized source-file extension reads as
|
|
41
|
+
// high-entropy the same way a real secret does — a class name doubling as its
|
|
42
|
+
// filename is the common case, but a deliberately-renamed or accidentally
|
|
43
|
+
// letters-only secret (no digits, so the hard-reject patterns above don't
|
|
44
|
+
// apply either) would match this shape too and MUST NOT be silently waved
|
|
45
|
+
// through: security review confirmed a full-exemption version of this check
|
|
46
|
+
// was a deterministic bypass (append ".php" to any 20+ char letters-only
|
|
47
|
+
// secret and it passed clean). So this only ever downgrades a match to a
|
|
48
|
+
// warning, never removes it from the reject reasons — see the two
|
|
49
|
+
// independent checks against looksRandomCandidates in scanForSecrets below,
|
|
50
|
+
// not a shortcut inside looksRandom itself. A bare identifier with no
|
|
51
|
+
// extension (a method name, not a filename) isn't covered by this at all —
|
|
52
|
+
// that shape is indistinguishable from a base64 secret fragment either way.
|
|
53
|
+
const CODE_FILENAME_RE = /^[A-Za-z]+\.(php|m?js|tsx?|jsx|py|rb|java|go|rs|vue|s?css|md|json|ya?ml|sh)$/i;
|
|
54
|
+
|
|
55
|
+
function looksLikeCodeFilename(rawToken) {
|
|
56
|
+
const stripped = stripEdgePunctuation(rawToken);
|
|
57
|
+
const match = stripped.match(CODE_FILENAME_RE);
|
|
58
|
+
// Requiring an internal case switch in the stem (the same signal used to
|
|
59
|
+
// detect base64 content elsewhere in this file) means a genuinely random
|
|
60
|
+
// single-case letter run plus a fake extension gets no special treatment
|
|
61
|
+
// at all — only tokens that already look like a real PascalCase/camelCase
|
|
62
|
+
// identifier reach the softer warning path below.
|
|
63
|
+
return match !== null && hasInternalCaseSwitch(match[0]);
|
|
64
|
+
}
|
|
65
|
+
|
|
40
66
|
function shannonEntropy(token) {
|
|
41
67
|
const counts = new Map();
|
|
42
68
|
for (const ch of token) counts.set(ch, (counts.get(ch) ?? 0) + 1);
|
|
@@ -183,8 +209,19 @@ export function scanForSecrets({ title = '', tags = [], body = '' } = {}) {
|
|
|
183
209
|
const reasons = [];
|
|
184
210
|
const warnings = [];
|
|
185
211
|
|
|
186
|
-
|
|
187
|
-
|
|
212
|
+
// Each field is tokenized on its own, and joinedChunkRuns runs separately
|
|
213
|
+
// per field, so a trailing tag word can never glue onto the next tag or
|
|
214
|
+
// onto the body's first word (see Trigger 3 in
|
|
215
|
+
// recall-secret-scanner-false-positives.md — tags echoing a phrase already
|
|
216
|
+
// repeated in the body were false-positiving via exactly this cross-field
|
|
217
|
+
// join). The hard-reject pass below intentionally keeps using the flat,
|
|
218
|
+
// cross-field `tokens`/`combined`/`despacedCombined`: those patterns match
|
|
219
|
+
// an exact literal prefix (AKIA, sk-, ghp_, eyJ, -----BEGIN), so joining
|
|
220
|
+
// across a field boundary can't turn them into a false positive the way
|
|
221
|
+
// entropy can.
|
|
222
|
+
const fieldTokenGroups = [title, ...tags, body].map(field => field.split(/\s+/).filter(Boolean));
|
|
223
|
+
const tokens = fieldTokenGroups.flat();
|
|
224
|
+
const candidates = [...tokens, ...fieldTokenGroups.flatMap(group => joinedChunkRuns(group))];
|
|
188
225
|
|
|
189
226
|
// A known secret shape (AWS key, API key prefix, JWT, PEM block...) is recognized
|
|
190
227
|
// by a specific literal prefix. Checked three ways, each catching what the
|
|
@@ -216,9 +253,20 @@ export function scanForSecrets({ title = '', tags = [], body = '' } = {}) {
|
|
|
216
253
|
// only split secrets), an email joined with an adjacent word can produce a long
|
|
217
254
|
// mixed string with incidentally high entropy. Emails get their own warning
|
|
218
255
|
// below — they're not secrets, so they shouldn't feed the random-string check.
|
|
219
|
-
|
|
256
|
+
//
|
|
257
|
+
// A code-filename-shaped candidate (looksLikeCodeFilename) is split out into
|
|
258
|
+
// its own warning instead of a reject reason: security review found that
|
|
259
|
+
// fully exempting this shape from the entropy check was a deterministic
|
|
260
|
+
// bypass (append ".php" to any letters-only secret and it passed clean).
|
|
261
|
+
// Downgrading to a warning — never silently dropping the signal — matches
|
|
262
|
+
// how an email address is already handled below.
|
|
263
|
+
const randomCandidates = candidates.filter(token => !EMAIL_RE.test(token) && looksRandom(token, combined));
|
|
264
|
+
if (randomCandidates.some(token => !looksLikeCodeFilename(token))) {
|
|
220
265
|
reasons.push('Contains a long, random-looking string that could be a secret.');
|
|
221
266
|
}
|
|
267
|
+
if (randomCandidates.some(token => looksLikeCodeFilename(token))) {
|
|
268
|
+
warnings.push('Contains a code-filename-shaped token that also reads as high-entropy — double-check it is not a credential.');
|
|
269
|
+
}
|
|
222
270
|
|
|
223
271
|
if (EMAIL_RE.test(combined)) {
|
|
224
272
|
warnings.push('Contains an email address.');
|