ticketlens 0.39.6 → 0.40.0
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 +1 -0
- package/bin/ticketlens.mjs +61 -1
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +14 -1
- package/skills/jtb/scripts/lib/error-reporter.mjs +110 -0
- package/skills/jtb/scripts/lib/profile-resolver.mjs +17 -0
- package/skills/jtb/scripts/lib/prompt-helpers.mjs +36 -5
- package/skills/jtb/scripts/lib/secret-scanner.mjs +22 -0
package/README.md
CHANGED
|
@@ -1121,6 +1121,7 @@ npm test
|
|
|
1121
1121
|
See [ROADMAP.md](ROADMAP.md) for the full plan.
|
|
1122
1122
|
|
|
1123
1123
|
Recently shipped:
|
|
1124
|
+
- **Console: Recall "select all N matching" bulk delete** — Gmail-style banner deletes every note matching your search/filters across all pages, not just the current one
|
|
1124
1125
|
- **Recall Stop-hook fix** — the end-of-session Recall reminder no longer fires because of file writes; only real ticket writes (comment, transition, assign, update) arm it. Also hardened against malformed transcripts and a session-id path-collision bug found by adversarial testing
|
|
1125
1126
|
- **Console: no repeat request on same-page menu clicks** — sidebar links, the header gear and Settings tabs skip the request when they point at the page you are on
|
|
1126
1127
|
- **Worklog** (`ticketlens worklog`, `ticket_worklog` MCP) — log time on Jira tickets, preview first. Pro tier
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { runSwitch } from '../skills/jtb/scripts/lib/profile-switcher.mjs';
|
|
|
16
16
|
import { runProfilesSetTeam } from '../skills/jtb/scripts/lib/profile-set-team.mjs';
|
|
17
17
|
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
|
-
import { deleteProfile, loadProfiles, saveCredentialKey, resolveRecallStrictnessTarget, saveProfileRecallStrictness, RECALL_STRICTNESS_LEVELS } from '../skills/jtb/scripts/lib/profile-resolver.mjs';
|
|
19
|
+
import { deleteProfile, loadProfiles, saveCredentialKey, resolveRecallStrictnessTarget, saveProfileRecallStrictness, RECALL_STRICTNESS_LEVELS, saveErrorReportingConsent } from '../skills/jtb/scripts/lib/profile-resolver.mjs';
|
|
20
20
|
import { run as runCache } from '../skills/jtb/scripts/lib/cache-manager.mjs';
|
|
21
21
|
import { runDoctor } from '../skills/jtb/scripts/lib/doctor-command.mjs';
|
|
22
22
|
import {
|
|
@@ -43,6 +43,16 @@ import { incrementInvocation, incrementCommand } from '../skills/jtb/scripts/lib
|
|
|
43
43
|
import { DEFAULT_CONFIG_DIR } from '../skills/jtb/scripts/lib/config.mjs';
|
|
44
44
|
import { checkTeamJiraConfigUpdate } from '../skills/jtb/scripts/lib/team-jira-sync.mjs';
|
|
45
45
|
import { maybeAutoFlush } from '../skills/jtb/scripts/lib/recall-queue.mjs';
|
|
46
|
+
import { maybeReportError } from '../skills/jtb/scripts/lib/error-reporter.mjs';
|
|
47
|
+
|
|
48
|
+
// Fire-and-forget (49e) — never awaited by a caller, mirrors checkForUpdate's
|
|
49
|
+
// own fire-and-forget style. maybeReportError already self-catches
|
|
50
|
+
// everything internally; the extra .catch(() => {}) here is a second,
|
|
51
|
+
// outermost safety net so this can never produce an unhandled rejection
|
|
52
|
+
// regardless of what changes inside that function later.
|
|
53
|
+
function reportError(err, command) {
|
|
54
|
+
maybeReportError(err, command).catch(() => {});
|
|
55
|
+
}
|
|
46
56
|
|
|
47
57
|
const TRACKED_COMMANDS = new Set([
|
|
48
58
|
'triage', 'fetch', 'get', 'compliance', 'review', 'standup',
|
|
@@ -121,6 +131,7 @@ switch (command) {
|
|
|
121
131
|
}).catch(err => {
|
|
122
132
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
123
133
|
process.exitCode = 1;
|
|
134
|
+
reportError(err, 'fetch');
|
|
124
135
|
});
|
|
125
136
|
break;
|
|
126
137
|
}
|
|
@@ -129,6 +140,7 @@ switch (command) {
|
|
|
129
140
|
runTriage(cmdArgs).catch(err => {
|
|
130
141
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
131
142
|
process.exitCode = 1;
|
|
143
|
+
reportError(err, 'triage');
|
|
132
144
|
});
|
|
133
145
|
break;
|
|
134
146
|
|
|
@@ -138,6 +150,7 @@ switch (command) {
|
|
|
138
150
|
runCollisions(cmdArgs).catch(err => {
|
|
139
151
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
140
152
|
process.exitCode = 1;
|
|
153
|
+
reportError(err, 'collisions');
|
|
141
154
|
});
|
|
142
155
|
break;
|
|
143
156
|
}
|
|
@@ -147,6 +160,7 @@ switch (command) {
|
|
|
147
160
|
runHistory(cmdArgs).catch(err => {
|
|
148
161
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
149
162
|
process.exitCode = 1;
|
|
163
|
+
reportError(err, 'history');
|
|
150
164
|
});
|
|
151
165
|
break;
|
|
152
166
|
}
|
|
@@ -156,6 +170,7 @@ switch (command) {
|
|
|
156
170
|
runStats(cmdArgs).catch(err => {
|
|
157
171
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
158
172
|
process.exitCode = 1;
|
|
173
|
+
reportError(err, 'stats');
|
|
159
174
|
});
|
|
160
175
|
break;
|
|
161
176
|
}
|
|
@@ -165,6 +180,7 @@ switch (command) {
|
|
|
165
180
|
runIssueTypes(cmdArgs).catch(err => {
|
|
166
181
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
167
182
|
process.exitCode = 1;
|
|
183
|
+
reportError(err, 'issue-types');
|
|
168
184
|
});
|
|
169
185
|
break;
|
|
170
186
|
}
|
|
@@ -176,6 +192,7 @@ switch (command) {
|
|
|
176
192
|
}).catch(err => {
|
|
177
193
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
178
194
|
process.exitCode = 1;
|
|
195
|
+
reportError(err, 'doctor');
|
|
179
196
|
});
|
|
180
197
|
break;
|
|
181
198
|
}
|
|
@@ -185,6 +202,7 @@ switch (command) {
|
|
|
185
202
|
runInit().catch(err => {
|
|
186
203
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
187
204
|
process.exitCode = 1;
|
|
205
|
+
reportError(err, 'init');
|
|
188
206
|
});
|
|
189
207
|
break;
|
|
190
208
|
|
|
@@ -193,6 +211,7 @@ switch (command) {
|
|
|
193
211
|
runSwitch().catch(err => {
|
|
194
212
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
195
213
|
process.exitCode = 1;
|
|
214
|
+
reportError(err, 'switch');
|
|
196
215
|
});
|
|
197
216
|
break;
|
|
198
217
|
|
|
@@ -219,6 +238,19 @@ switch (command) {
|
|
|
219
238
|
break;
|
|
220
239
|
}
|
|
221
240
|
|
|
241
|
+
if (cmdArgs[0] === 'set' && cmdArgs[1] === 'errorReporting') {
|
|
242
|
+
const s = createStyler({ isTTY: process.stdout.isTTY });
|
|
243
|
+
const value = cmdArgs[2];
|
|
244
|
+
if (value !== 'on' && value !== 'off') {
|
|
245
|
+
process.stderr.write(`${s.red('✖')} Missing or invalid value.\n Usage: ticketlens config set errorReporting <on|off>\n`);
|
|
246
|
+
process.exitCode = 1;
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
saveErrorReportingConsent(value === 'on', DEFAULT_CONFIG_DIR);
|
|
250
|
+
process.stdout.write(` ${s.green('✔')} Error reporting ${s.bold(s.cyan(value))}\n`);
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
|
|
222
254
|
if (cmdArgs[0] === 'set' && cmdArgs[1] === 'recallStrictness') {
|
|
223
255
|
const s = createStyler({ isTTY: process.stdout.isTTY });
|
|
224
256
|
const value = cmdArgs[2];
|
|
@@ -265,6 +297,7 @@ switch (command) {
|
|
|
265
297
|
})().catch(err => {
|
|
266
298
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
267
299
|
process.exitCode = 1;
|
|
300
|
+
reportError(err, 'config');
|
|
268
301
|
});
|
|
269
302
|
break;
|
|
270
303
|
}
|
|
@@ -290,6 +323,7 @@ switch (command) {
|
|
|
290
323
|
}).catch(err => {
|
|
291
324
|
process.stderr.write(`\n ${s.red('✖')} Activation error: ${err.message}\n\n`);
|
|
292
325
|
process.exitCode = 1;
|
|
326
|
+
reportError(err, 'activate');
|
|
293
327
|
});
|
|
294
328
|
break;
|
|
295
329
|
}
|
|
@@ -408,6 +442,7 @@ switch (command) {
|
|
|
408
442
|
runProfilesSetTeam(profileName, teamName).catch(err => {
|
|
409
443
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
410
444
|
process.exitCode = 1;
|
|
445
|
+
reportError(err, 'profiles');
|
|
411
446
|
});
|
|
412
447
|
break;
|
|
413
448
|
}
|
|
@@ -422,6 +457,7 @@ switch (command) {
|
|
|
422
457
|
runCache(cmdArgs).catch(err => {
|
|
423
458
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
424
459
|
process.exitCode = 1;
|
|
460
|
+
reportError(err, 'cache');
|
|
425
461
|
});
|
|
426
462
|
break;
|
|
427
463
|
|
|
@@ -489,6 +525,7 @@ switch (command) {
|
|
|
489
525
|
runFetch(['install-hooks', ...cmdArgs]).catch(err => {
|
|
490
526
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
491
527
|
process.exitCode = 1;
|
|
528
|
+
reportError(err, 'install-hooks');
|
|
492
529
|
});
|
|
493
530
|
break;
|
|
494
531
|
|
|
@@ -497,6 +534,7 @@ switch (command) {
|
|
|
497
534
|
runFetch(['pr', ...cmdArgs]).catch(err => {
|
|
498
535
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
499
536
|
process.exitCode = 1;
|
|
537
|
+
reportError(err, 'pr');
|
|
500
538
|
});
|
|
501
539
|
break;
|
|
502
540
|
|
|
@@ -505,6 +543,7 @@ switch (command) {
|
|
|
505
543
|
runFetch(['review', ...cmdArgs]).catch(err => {
|
|
506
544
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
507
545
|
process.exitCode = 1;
|
|
546
|
+
reportError(err, 'review');
|
|
508
547
|
});
|
|
509
548
|
break;
|
|
510
549
|
|
|
@@ -513,6 +552,7 @@ switch (command) {
|
|
|
513
552
|
runFetch(['standup', ...cmdArgs]).catch(err => {
|
|
514
553
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
515
554
|
process.exitCode = 1;
|
|
555
|
+
reportError(err, 'standup');
|
|
516
556
|
});
|
|
517
557
|
break;
|
|
518
558
|
|
|
@@ -521,6 +561,7 @@ switch (command) {
|
|
|
521
561
|
runFetch(['ledger', ...cmdArgs]).catch(err => {
|
|
522
562
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
523
563
|
process.exitCode = 1;
|
|
564
|
+
reportError(err, 'ledger');
|
|
524
565
|
});
|
|
525
566
|
break;
|
|
526
567
|
|
|
@@ -529,6 +570,7 @@ switch (command) {
|
|
|
529
570
|
runFetch(['compliance', ...cmdArgs]).catch(err => {
|
|
530
571
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
531
572
|
process.exitCode = 1;
|
|
573
|
+
reportError(err, 'compliance');
|
|
532
574
|
});
|
|
533
575
|
break;
|
|
534
576
|
|
|
@@ -549,6 +591,7 @@ switch (command) {
|
|
|
549
591
|
})().catch(err => {
|
|
550
592
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
551
593
|
process.exitCode = 1;
|
|
594
|
+
reportError(err, 'login');
|
|
552
595
|
});
|
|
553
596
|
break;
|
|
554
597
|
}
|
|
@@ -589,6 +632,7 @@ switch (command) {
|
|
|
589
632
|
})().catch(err => {
|
|
590
633
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
591
634
|
process.exitCode = 1;
|
|
635
|
+
reportError(err, 'sync');
|
|
592
636
|
});
|
|
593
637
|
break;
|
|
594
638
|
}
|
|
@@ -707,6 +751,7 @@ switch (command) {
|
|
|
707
751
|
})().catch(err => {
|
|
708
752
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
709
753
|
process.exitCode = 1;
|
|
754
|
+
reportError(err, 'cloud-keys');
|
|
710
755
|
});
|
|
711
756
|
break;
|
|
712
757
|
}
|
|
@@ -720,6 +765,7 @@ switch (command) {
|
|
|
720
765
|
}).catch(err => {
|
|
721
766
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
722
767
|
process.exitCode = 1;
|
|
768
|
+
reportError(err, 'note');
|
|
723
769
|
});
|
|
724
770
|
break;
|
|
725
771
|
}
|
|
@@ -730,6 +776,7 @@ switch (command) {
|
|
|
730
776
|
}).catch(err => {
|
|
731
777
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
732
778
|
process.exitCode = 1;
|
|
779
|
+
reportError(err, 'note');
|
|
733
780
|
});
|
|
734
781
|
break;
|
|
735
782
|
}
|
|
@@ -740,6 +787,7 @@ switch (command) {
|
|
|
740
787
|
}).catch(err => {
|
|
741
788
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
742
789
|
process.exitCode = 1;
|
|
790
|
+
reportError(err, 'note');
|
|
743
791
|
});
|
|
744
792
|
break;
|
|
745
793
|
}
|
|
@@ -757,6 +805,7 @@ switch (command) {
|
|
|
757
805
|
}).catch(err => {
|
|
758
806
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
759
807
|
process.exitCode = 1;
|
|
808
|
+
reportError(err, 'recall');
|
|
760
809
|
});
|
|
761
810
|
break;
|
|
762
811
|
}
|
|
@@ -767,6 +816,7 @@ switch (command) {
|
|
|
767
816
|
}).catch(err => {
|
|
768
817
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
769
818
|
process.exitCode = 1;
|
|
819
|
+
reportError(err, 'recall');
|
|
770
820
|
});
|
|
771
821
|
break;
|
|
772
822
|
}
|
|
@@ -776,6 +826,7 @@ switch (command) {
|
|
|
776
826
|
}).catch(err => {
|
|
777
827
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
778
828
|
process.exitCode = 1;
|
|
829
|
+
reportError(err, 'recall');
|
|
779
830
|
});
|
|
780
831
|
break;
|
|
781
832
|
}
|
|
@@ -803,6 +854,7 @@ switch (command) {
|
|
|
803
854
|
}).catch(err => {
|
|
804
855
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
805
856
|
process.exitCode = 1;
|
|
857
|
+
reportError(err, 'comment');
|
|
806
858
|
});
|
|
807
859
|
break;
|
|
808
860
|
}
|
|
@@ -819,6 +871,7 @@ switch (command) {
|
|
|
819
871
|
}).catch(err => {
|
|
820
872
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
821
873
|
process.exitCode = 1;
|
|
874
|
+
reportError(err, 'transition');
|
|
822
875
|
});
|
|
823
876
|
break;
|
|
824
877
|
}
|
|
@@ -831,6 +884,7 @@ switch (command) {
|
|
|
831
884
|
}).catch(err => {
|
|
832
885
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
833
886
|
process.exitCode = 1;
|
|
887
|
+
reportError(err, 'assign');
|
|
834
888
|
});
|
|
835
889
|
break;
|
|
836
890
|
}
|
|
@@ -843,6 +897,7 @@ switch (command) {
|
|
|
843
897
|
}).catch(err => {
|
|
844
898
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
845
899
|
process.exitCode = 1;
|
|
900
|
+
reportError(err, 'worklog');
|
|
846
901
|
});
|
|
847
902
|
break;
|
|
848
903
|
}
|
|
@@ -855,6 +910,7 @@ switch (command) {
|
|
|
855
910
|
}).catch(err => {
|
|
856
911
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
857
912
|
process.exitCode = 1;
|
|
913
|
+
reportError(err, 'duplicates');
|
|
858
914
|
});
|
|
859
915
|
break;
|
|
860
916
|
}
|
|
@@ -871,6 +927,7 @@ switch (command) {
|
|
|
871
927
|
}).catch(err => {
|
|
872
928
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
873
929
|
process.exitCode = 1;
|
|
930
|
+
reportError(err, 'link');
|
|
874
931
|
});
|
|
875
932
|
break;
|
|
876
933
|
}
|
|
@@ -883,6 +940,7 @@ switch (command) {
|
|
|
883
940
|
}).catch(err => {
|
|
884
941
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
885
942
|
process.exitCode = 1;
|
|
943
|
+
reportError(err, 'update');
|
|
886
944
|
});
|
|
887
945
|
break;
|
|
888
946
|
}
|
|
@@ -895,6 +953,7 @@ switch (command) {
|
|
|
895
953
|
}).catch(err => {
|
|
896
954
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
897
955
|
process.exitCode = 1;
|
|
956
|
+
reportError(err, 'create');
|
|
898
957
|
});
|
|
899
958
|
break;
|
|
900
959
|
}
|
|
@@ -916,6 +975,7 @@ switch (command) {
|
|
|
916
975
|
})().catch(err => {
|
|
917
976
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
918
977
|
process.exitCode = 1;
|
|
978
|
+
reportError(err, command);
|
|
919
979
|
});
|
|
920
980
|
break;
|
|
921
981
|
}
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.45.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.
|
|
@@ -510,6 +510,19 @@ A report with `ok: false` is a successful tool call describing failures, not a t
|
|
|
510
510
|
|
|
511
511
|
---
|
|
512
512
|
|
|
513
|
+
## Opt-in error reporting
|
|
514
|
+
|
|
515
|
+
Off by default. The first time a command hits a genuine unexpected error while running in a real interactive terminal, TicketLens asks once — "Would you like TicketLens to send reports about issues, bugs, and errors to improve your experience?" — and remembers the answer permanently, never asking again. It never asks under an MCP-connected harness, CI, or any piped/non-interactive invocation; in those contexts an undecided consent state just stays undecided and nothing is sent.
|
|
516
|
+
|
|
517
|
+
When consent is `on`, an unexpected error triggers a best-effort, fire-and-forget report — CLI version, OS, the command name, the error message, and a stack trace. This never delays or affects the command's own exit code or stderr output; a failed or slow send is silently dropped. The message and stack trace are scanned for secret-shaped content (same scanner `note add` uses) before anything leaves the machine — a flagged report is dropped, not sent redacted. No account, profile name, ticket data, or credential ever leaves the machine as part of this — reports are anonymous.
|
|
518
|
+
|
|
519
|
+
```bash
|
|
520
|
+
ticketlens config set errorReporting on # opt in explicitly, skip the prompt
|
|
521
|
+
ticketlens config set errorReporting off # opt out — also the way to change your mind after saying yes
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
---
|
|
525
|
+
|
|
513
526
|
## Gaps — cross-ticket evidence (Pro)
|
|
514
527
|
|
|
515
528
|
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).
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in CLI error/diagnostic reporting (49e). Wraps every command's
|
|
3
|
+
* top-level .catch() in bin/ticketlens.mjs via handleCommandError() there.
|
|
4
|
+
*
|
|
5
|
+
* Consent is asked at most once, ever, and only when a real error just
|
|
6
|
+
* happened and the process's stdin+stderr are both a real terminal, outside
|
|
7
|
+
* CI — never under MCP stdio (that path has no wiring to this function at
|
|
8
|
+
* all) or CI. isInteractive is a TTY check, not an output-format check: a
|
|
9
|
+
* human can be genuinely present and able to answer a stderr prompt
|
|
10
|
+
* regardless of what flags the command itself was given.
|
|
11
|
+
*
|
|
12
|
+
* The prompt is still bounded (PROMPT_TIMEOUT_MS, via promptYN's timeoutMs —
|
|
13
|
+
* see prompt-helpers.mjs) so an unanswered question can never hang the
|
|
14
|
+
* process indefinitely (security review finding, 2026-09-23: the first
|
|
15
|
+
* version had no timeout here — an orphaned raw-mode stdin listener keeps
|
|
16
|
+
* Node's event loop alive regardless of what a caller does with the
|
|
17
|
+
* returned promise, so the timeout has to live inside promptYN itself, not
|
|
18
|
+
* be raced against externally). A timeout resolves to `null`, distinct from
|
|
19
|
+
* a real `false` answer — it is never persisted, so an AFK/non-responsive
|
|
20
|
+
* user is asked again on the next real error, not permanently opted out.
|
|
21
|
+
*
|
|
22
|
+
* Once a real (non-null) answer is given, it's never asked again
|
|
23
|
+
* (loadErrorReportingConsent/saveErrorReportingConsent, profile-resolver.mjs).
|
|
24
|
+
*
|
|
25
|
+
* Sending is entirely best-effort: a broken network, a rejected promptFn, or
|
|
26
|
+
* a scan false-positive must never surface past this function, since it
|
|
27
|
+
* runs inside an error path that already has its own real error to report
|
|
28
|
+
* to the user via stderr.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
32
|
+
import { getVersion } from './config.mjs';
|
|
33
|
+
import { apiBase } from './api-utils.mjs';
|
|
34
|
+
import { promptYN } from './prompt-helpers.mjs';
|
|
35
|
+
import { loadErrorReportingConsent, saveErrorReportingConsent } from './profile-resolver.mjs';
|
|
36
|
+
import { readLicense } from './license.mjs';
|
|
37
|
+
import { scanForSecrets, containsKnownSecretPattern } from './secret-scanner.mjs';
|
|
38
|
+
|
|
39
|
+
const CONSENT_QUESTION = 'Would you like TicketLens to send reports about issues, bugs, and errors to improve your experience?';
|
|
40
|
+
// Human reaction time, not a network call — generous, but still a hard
|
|
41
|
+
// upper bound on how long an unanswered prompt can keep the process open.
|
|
42
|
+
const PROMPT_TIMEOUT_MS = 15_000;
|
|
43
|
+
|
|
44
|
+
function defaultIsInteractive() {
|
|
45
|
+
return Boolean(process.stdin.isTTY && process.stderr.isTTY && !process.env.CI);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function maybeReportError(err, command, {
|
|
49
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
50
|
+
isInteractive = defaultIsInteractive(),
|
|
51
|
+
promptFn = promptYN,
|
|
52
|
+
fetcher = globalThis.fetch,
|
|
53
|
+
stream = process.stderr,
|
|
54
|
+
promptTimeoutMs = PROMPT_TIMEOUT_MS,
|
|
55
|
+
} = {}) {
|
|
56
|
+
try {
|
|
57
|
+
let consent = loadErrorReportingConsent(configDir);
|
|
58
|
+
|
|
59
|
+
if (consent === undefined) {
|
|
60
|
+
// Never ask outside a real terminal — undecided stays undecided until
|
|
61
|
+
// a real interactive session hits an error and can actually answer.
|
|
62
|
+
if (!isInteractive) return;
|
|
63
|
+
consent = await promptFn(CONSENT_QUESTION, { stream, timeoutMs: promptTimeoutMs });
|
|
64
|
+
// null = timed out with no answer — stays undecided, ask again next
|
|
65
|
+
// time, never persisted as a real (permanent) decision.
|
|
66
|
+
if (consent === null) return;
|
|
67
|
+
saveErrorReportingConsent(consent, configDir);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!consent) return;
|
|
71
|
+
|
|
72
|
+
const message = err?.message ?? String(err);
|
|
73
|
+
const stackTrace = err?.stack;
|
|
74
|
+
|
|
75
|
+
// Client-side pre-check only — the backend re-scans independently
|
|
76
|
+
// (RecallSecretScanner, defense in depth), so this just avoids a
|
|
77
|
+
// pointless network call when the server would reject it anyway.
|
|
78
|
+
//
|
|
79
|
+
// message uses the full entropy-aware scan (real free text — a user- or
|
|
80
|
+
// library-authored string, same as a Recall note body). stack_trace
|
|
81
|
+
// uses the narrower known-pattern-only check: a V8 stack trace's own
|
|
82
|
+
// "at fn (path:line:col)" frames are machine-generated and false-positive
|
|
83
|
+
// almost every line on the entropy heuristic (found 2026-09-23) — a real
|
|
84
|
+
// secret embedded in one still has a literal known prefix and is still
|
|
85
|
+
// caught by containsKnownSecretPattern.
|
|
86
|
+
const messageScan = scanForSecrets({ title: command ?? '', body: message });
|
|
87
|
+
if (messageScan.rejected) return;
|
|
88
|
+
if (stackTrace && containsKnownSecretPattern(stackTrace)) return;
|
|
89
|
+
|
|
90
|
+
const license = readLicense(configDir);
|
|
91
|
+
const payload = {
|
|
92
|
+
cli_version: getVersion(),
|
|
93
|
+
os: process.platform,
|
|
94
|
+
command: command || undefined,
|
|
95
|
+
message,
|
|
96
|
+
stack_trace: stackTrace || undefined,
|
|
97
|
+
profile_tier: license?.tier || undefined,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
await fetcher(`${apiBase()}/v1/reports`, {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: { 'Content-Type': 'application/json' },
|
|
103
|
+
body: JSON.stringify(payload),
|
|
104
|
+
signal: AbortSignal.timeout(5000),
|
|
105
|
+
});
|
|
106
|
+
} catch {
|
|
107
|
+
// Best-effort — never let a broken reporting path affect the real error
|
|
108
|
+
// the caller is already handling.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -80,6 +80,23 @@ export function loadTeams(configDir = DEFAULT_CONFIG_DIR) {
|
|
|
80
80
|
return loadProfiles(configDir)?.teams ?? [];
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// Opt-in CLI error/diagnostic reporting (49e) — global, not per-profile: one
|
|
84
|
+
// machine, one consent decision, unlike recallStrictness. Tri-state via
|
|
85
|
+
// `undefined` (never asked) vs. `true`/`false` (asked once, answer
|
|
86
|
+
// persisted) — the consent prompt fires at most once per install.
|
|
87
|
+
export function saveErrorReportingConsent(consent, configDir = DEFAULT_CONFIG_DIR) {
|
|
88
|
+
mkdirSync(configDir, { recursive: true });
|
|
89
|
+
const profilesPath = join(configDir, 'profiles.json');
|
|
90
|
+
const config = loadProfiles(configDir) || { profiles: {} };
|
|
91
|
+
config.errorReporting = consent;
|
|
92
|
+
writeFileSync(profilesPath, JSON.stringify(config, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
93
|
+
invalidateProfilesCache(configDir);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function loadErrorReportingConsent(configDir = DEFAULT_CONFIG_DIR) {
|
|
97
|
+
return loadProfiles(configDir)?.errorReporting;
|
|
98
|
+
}
|
|
99
|
+
|
|
83
100
|
export function saveProfile(name, profileData, credData, configDir = DEFAULT_CONFIG_DIR) {
|
|
84
101
|
mkdirSync(configDir, { recursive: true });
|
|
85
102
|
const profilesPath = join(configDir, 'profiles.json');
|
|
@@ -194,22 +194,53 @@ export function promptRecallPulse(question, { stream = process.stderr } = {}) {
|
|
|
194
194
|
}));
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
-
|
|
197
|
+
/**
|
|
198
|
+
* @param {string} question
|
|
199
|
+
* @param {{ stream?: NodeJS.WriteStream, timeoutMs?: number }} [opts]
|
|
200
|
+
* @param {number} [opts.timeoutMs] - Optional. When set, an unanswered
|
|
201
|
+
* prompt resolves to `null` (not `false`) after this many ms, cleanly
|
|
202
|
+
* releasing raw mode and removing the listener first — this is what
|
|
203
|
+
* actually lets the process exit; merely racing the returned promise at
|
|
204
|
+
* the call site does nothing, since an orphaned raw-mode stdin listener
|
|
205
|
+
* keeps the event loop alive regardless. `null` (not `false`), so a
|
|
206
|
+
* caller can tell "timed out" apart from a real "no" answer and, e.g.,
|
|
207
|
+
* avoid permanently persisting a decision the user never actually made.
|
|
208
|
+
* Omitted entirely for the setup-wizard callers (config-wizard.mjs,
|
|
209
|
+
* init-wizard.mjs, onboarding.mjs) — an unbounded wait is the right
|
|
210
|
+
* behavior for a prompt the user is actively mid-flow on.
|
|
211
|
+
*/
|
|
212
|
+
export function promptYN(question, { stream = process.stderr, timeoutMs } = {}) {
|
|
198
213
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
199
214
|
stream.write(`\n ${question} ${s.dim('y/N')} `);
|
|
200
215
|
return flushStdin().then(() => new Promise(res => {
|
|
201
216
|
const stdin = process.stdin;
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
stdin.setEncoding('utf8');
|
|
205
|
-
function onData(char) {
|
|
217
|
+
let timer = null;
|
|
218
|
+
function cleanup() {
|
|
206
219
|
stdin.setRawMode(false);
|
|
207
220
|
stdin.pause();
|
|
208
221
|
stdin.removeListener('data', onData);
|
|
222
|
+
if (timer) clearTimeout(timer);
|
|
223
|
+
}
|
|
224
|
+
function onData(char) {
|
|
225
|
+
cleanup();
|
|
209
226
|
stream.write('\n');
|
|
210
227
|
if (char === '\x03') process.exit(0);
|
|
211
228
|
res(char === 'y' || char === 'Y');
|
|
212
229
|
}
|
|
230
|
+
stdin.setRawMode(true);
|
|
231
|
+
stdin.resume();
|
|
232
|
+
stdin.setEncoding('utf8');
|
|
213
233
|
stdin.on('data', onData);
|
|
234
|
+
if (timeoutMs) {
|
|
235
|
+
// Deliberately NOT unref'd: this timer is what bounds the wait — an
|
|
236
|
+
// unref'd timer doesn't count toward Node keeping the process alive,
|
|
237
|
+
// so if nothing else were holding the loop open, the process (or a
|
|
238
|
+
// test runner's own loop-empty detection) could end before the timer
|
|
239
|
+
// ever fires, leaving the promise permanently pending. A live TTY's
|
|
240
|
+
// own stdin.resume() already keeps the process alive regardless; this
|
|
241
|
+
// timer only needs to fire and clean up before that natural lifetime
|
|
242
|
+
// runs out, which is the entire point of having it.
|
|
243
|
+
timer = setTimeout(() => { cleanup(); stream.write('\n'); res(null); }, timeoutMs);
|
|
244
|
+
}
|
|
214
245
|
}));
|
|
215
246
|
}
|
|
@@ -475,6 +475,28 @@ function joinedChunkRuns(tokens, { stopAtLabelWords = true } = {}) {
|
|
|
475
475
|
return runs;
|
|
476
476
|
}
|
|
477
477
|
|
|
478
|
+
/**
|
|
479
|
+
* HARD_REJECT_PATTERNS only — no tokenization, no entropy heuristic. For
|
|
480
|
+
* machine-generated text that is never free-form user prose (a V8 stack
|
|
481
|
+
* trace's "at fn (path:line:col)" frames, in practice), where the entropy
|
|
482
|
+
* heuristic false-positives on nearly every line: short, punctuation-dense,
|
|
483
|
+
* path-heavy tokens read as "random" to it even though none of it is a
|
|
484
|
+
* secret. A real secret embedded in such text (e.g. a token baked into an
|
|
485
|
+
* error message that then appears in the trace's own first line) still has
|
|
486
|
+
* a literal prefix (AKIA/eyJ/sk-/-----BEGIN/etc.) and is still caught here —
|
|
487
|
+
* only the generic "long random-looking string" class of rejection is
|
|
488
|
+
* skipped, deliberately, for this one call site.
|
|
489
|
+
*
|
|
490
|
+
* @param {string} text
|
|
491
|
+
* @returns {boolean}
|
|
492
|
+
*/
|
|
493
|
+
export function containsKnownSecretPattern(text) {
|
|
494
|
+
const tokens = text.split(WHITESPACE_SPLIT_RE).filter(Boolean);
|
|
495
|
+
const despaced = text.replace(WHITESPACE_STRIP_RE, '');
|
|
496
|
+
const runs = joinedChunkRuns(tokens, { stopAtLabelWords: false });
|
|
497
|
+
return HARD_REJECT_PATTERNS.some(({ re }) => re.test(text) || runs.some(c => re.test(c)) || re.test(despaced));
|
|
498
|
+
}
|
|
499
|
+
|
|
478
500
|
/**
|
|
479
501
|
* @param {{ title?: string, tags?: string[], body?: string }} note
|
|
480
502
|
* @returns {{ rejected: boolean, reasons: string[], warnings: string[] }}
|