ticketlens 0.3.1 → 0.4.2

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 CHANGED
@@ -211,6 +211,53 @@ Flag validation provides actionable hints:
211
211
 
212
212
  ---
213
213
 
214
+ ### Compliance
215
+
216
+ ```bash
217
+ ticketlens compliance <TICKET-KEY> # Check ticket requirements against local diff [Pro/Free 3/mo]
218
+ ticketlens compliance <TICKET-KEY> --profile=acme # Specify a profile
219
+ ticketlens compliance <TICKET-KEY> --plain # Plain markdown output
220
+ ```
221
+
222
+ Runs the same compliance check as `ticketlens CNV1-2 --compliance` but as a dedicated subcommand — useful when you want to check compliance without fetching the full ticket brief. Free accounts get 3 checks per month; Pro is unlimited.
223
+
224
+ ---
225
+
226
+ ### Compliance Ledger
227
+
228
+ ```bash
229
+ ticketlens ledger # View the local compliance audit ledger [Pro]
230
+ ticketlens ledger --plain # Plain markdown output
231
+ ```
232
+
233
+ Displays the append-only local ledger of all compliance checks run on this machine. Useful for SOC 2 / HIPAA audit trails. Requires a Pro license.
234
+
235
+ ---
236
+
237
+ ### Git Hook
238
+
239
+ ```bash
240
+ ticketlens install-hooks # Install pre-push compliance gate [Pro]
241
+ ticketlens install-hooks --uninstall # Remove installed hooks
242
+ ```
243
+
244
+ Installs a `pre-push` git hook that runs `ticketlens compliance` on every push. Blocks the push if compliance coverage falls below the configured threshold. Requires a Pro license.
245
+
246
+ ---
247
+
248
+ ### PR Description
249
+
250
+ ```bash
251
+ ticketlens pr <TICKET-KEY> # Generate PR description from ticket [Pro]
252
+ ticketlens pr <TICKET-KEY> --profile=acme # Specify a profile
253
+ ticketlens pr <TICKET-KEY> --plain # Plain markdown output
254
+ ticketlens pr <TICKET-KEY> | pbcopy # Copy to clipboard
255
+ ```
256
+
257
+ Generates a PR description template pre-filled with the ticket summary, acceptance criteria, and compliance coverage. Requires a Pro license.
258
+
259
+ ---
260
+
214
261
  ### Standup
215
262
 
216
263
  ```bash
@@ -295,12 +342,16 @@ Stores the schedule as a cron entry. Delivers your triage digest at the configur
295
342
  ```bash
296
343
  ticketlens login # Open browser → authorize in Console → token saved automatically
297
344
  ticketlens login --manual # Paste a token instead (CI/headless environments)
345
+ ticketlens logout # Revoke and remove the stored CLI token
346
+ ticketlens sync # Pull your latest tracker profiles from the Console
298
347
  ```
299
348
 
300
349
  `ticketlens login` opens the TicketLens Console in your default browser. Click **Authorize**, and the CLI receives your token via a one-shot localhost callback — no copy-pasting. Cancelling in the browser exits the CLI cleanly.
301
350
 
302
351
  Use `--manual` when there is no GUI (CI runners, SSH sessions, containers).
303
352
 
353
+ `ticketlens sync` pulls any tracker profiles you have configured in the Console and writes them locally, keeping your CLI in sync with your team settings without re-running `init`.
354
+
304
355
  ---
305
356
 
306
357
  ### License
@@ -483,9 +534,21 @@ ticketlens schedule # Interactive wizard — set time,
483
534
  ticketlens schedule --stop # Cancel the scheduled digest [Pro]
484
535
  ticketlens schedule --status # Show current schedule [Pro]
485
536
 
537
+ # ── Compliance ────────────────────────────────────────────────────────────────
538
+ ticketlens compliance <TICKET-KEY> # Check ticket requirements against local diff [Pro/Free 3/mo]
539
+ ticketlens ledger # View local compliance audit ledger [Pro]
540
+ ticketlens install-hooks # Install pre-push compliance gate [Pro]
541
+ ticketlens install-hooks --uninstall # Remove installed hooks [Pro]
542
+
543
+ # ── PR Description ─────────────────────────────────────────────────────────────
544
+ ticketlens pr <TICKET-KEY> # Generate PR description from ticket [Pro]
545
+ ticketlens pr <TICKET-KEY> | pbcopy # Copy to clipboard [Pro]
546
+
486
547
  # ── Login ─────────────────────────────────────────────────────────────────────
487
548
  ticketlens login # Browser flow — opens Console, token saved automatically
488
549
  ticketlens login --manual # Paste flow — for CI/headless environments
550
+ ticketlens logout # Revoke and remove stored CLI token
551
+ ticketlens sync # Pull tracker profiles from the Console
489
552
 
490
553
  # ── License and account ────────────────────────────────────────────────────────
491
554
  ticketlens license # Show license tier and status
@@ -129,6 +129,9 @@ switch (command) {
129
129
  process.stderr.write(`\n ${s.red('✖')} Activation failed: ${result.error}\n\n`);
130
130
  process.exitCode = 1;
131
131
  }
132
+ }).catch(err => {
133
+ process.stderr.write(`\n ${s.red('✖')} Activation error: ${err.message}\n\n`);
134
+ process.exitCode = 1;
132
135
  });
133
136
  break;
134
137
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.3.1",
3
+ "version": "0.4.2",
4
4
  "description": "Jira CLI for developers — fetch ticket context, triage your queue, and stop tab-switching. Zero dependencies, all local.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,4 +1,4 @@
1
- <!-- jtb-skill-version: 0.3.1 -->
1
+ <!-- jtb-skill-version: 0.4.2 -->
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.
@@ -249,6 +249,12 @@ Generates a structured handoff brief synthesised from the ticket's full comment
249
249
 
250
250
  Output is a concise markdown document, not a full TicketBrief. No plan mode — output is displayed and the workflow stops.
251
251
 
252
+ ### triage --push compliance enrichment (Pro)
253
+
254
+ When `--push` is run by a Pro-licensed user, the local compliance ledger (written by `--compliance` runs) is read and merged into the snapshot before sending. Each ticket's `compliance_status` (`pass`/`gap`) and `compliance_coverage` (%) are included in the push payload. This feeds the **Compliance Analytics** dashboard in the Console (`/console/admin/compliance-analytics`), which shows gap-rate trends by project, ticket status, and week.
255
+
256
+ Non-Pro users push with `compliance_status: unknown` — no data is lost, and the analytics page simply shows no compliance data for those snapshots.
257
+
252
258
  ### --plain / --styled
253
259
 
254
260
  Control output formatting:
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Shared API URL utilities for triage push, share, and collisions.
3
+ * Centralised here to avoid triplicating the regex and warning logic.
4
+ */
5
+
6
+ export const DEFAULT_API_BASE = 'http://ticketlens.test';
7
+
8
+ // Matches localhost, 127.0.0.1, and any hostname ending in .test or .local,
9
+ // with an optional port — all treated as local-only addresses.
10
+ const LOCAL_HOST_RE = /^https?:\/\/(localhost|127\.0\.0\.1|[^/:]+\.(test|local))(:\d+)?(\/|$)/i;
11
+
12
+ export function apiBase() {
13
+ return process.env?.TICKETLENS_API_URL ?? DEFAULT_API_BASE;
14
+ }
15
+
16
+ export function warnIfInsecure(url, warn) {
17
+ if (url.startsWith('http://') && !LOCAL_HOST_RE.test(url)) {
18
+ warn('⚠ Warning: TICKETLENS_API_URL uses an unencrypted HTTP connection. Use https:// for production.\n');
19
+ }
20
+ }
@@ -47,7 +47,7 @@ export function readBriefCache(ticketKey, profileName, depth, configDir = DEFAUL
47
47
  }
48
48
 
49
49
  const age = Date.now() - new Date(data.fetchedAt).getTime();
50
- if (age > ttlMs) {
50
+ if (isNaN(age) || age > ttlMs) {
51
51
  try { fs.unlinkSync(filePath); } catch { /* non-fatal */ }
52
52
  return null;
53
53
  }
@@ -4,7 +4,7 @@
4
4
  * plaintext so the CLI can use it as a Bearer token.
5
5
  */
6
6
 
7
- import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from 'node:fs';
7
+ import { readFileSync, writeFileSync, chmodSync, existsSync, mkdirSync } from 'node:fs';
8
8
  import { join } from 'node:path';
9
9
  import { DEFAULT_CONFIG_DIR } from './config.mjs';
10
10
 
@@ -28,13 +28,14 @@ export function readCliToken(configDir = DEFAULT_CONFIG_DIR) {
28
28
  export function saveCliToken(token, configDir = DEFAULT_CONFIG_DIR) {
29
29
  mkdirSync(configDir, { recursive: true });
30
30
  const p = cliTokenPath(configDir);
31
- writeFileSync(p, JSON.stringify({ token }, null, 2) + '\n', 'utf8');
31
+ writeFileSync(p, JSON.stringify({ token }, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
32
32
  chmodSync(p, 0o600);
33
33
  }
34
34
 
35
35
  export function deleteCliToken(configDir = DEFAULT_CONFIG_DIR) {
36
36
  const p = cliTokenPath(configDir);
37
37
  if (existsSync(p)) {
38
- writeFileSync(p, JSON.stringify({}, null, 2) + '\n', 'utf8');
38
+ writeFileSync(p, JSON.stringify({}, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
39
+ chmodSync(p, 0o600);
39
40
  }
40
41
  }
@@ -71,7 +71,7 @@ export function classifyError(err, { baseUrl, profileName } = {}) {
71
71
  if (status === 401) {
72
72
  return {
73
73
  message: `Authentication failed for ${profile}`,
74
- hint: 'Your credentials may have expired. Check your API token or PAT in ~/.ticketlens/credentials.json.',
74
+ hint: 'Your credentials may have expired. Check your API token or PAT in your TicketLens credentials file.',
75
75
  };
76
76
  }
77
77
 
@@ -75,7 +75,9 @@ export async function fetchCurrentUser(opts = {}) {
75
75
  const response = await fetcher(url, fetchOpts);
76
76
 
77
77
  if (!response.ok) {
78
- throw new Error(`Jira API error ${response.status} (${response.statusText}) fetching current user`);
78
+ const err = new Error(`Jira API error ${response.status} fetching current user`);
79
+ err.status = response.status;
80
+ throw err;
79
81
  }
80
82
 
81
83
  const raw = await response.json();
@@ -98,7 +100,9 @@ export async function fetchStatuses(opts = {}) {
98
100
  const response = await fetcher(url, fetchOpts);
99
101
 
100
102
  if (!response.ok) {
101
- throw new Error(`Jira API error ${response.status} (${response.statusText}) fetching statuses`);
103
+ const err = new Error(`Jira API error ${response.status} fetching statuses`);
104
+ err.status = response.status;
105
+ throw err;
102
106
  }
103
107
 
104
108
  const raw = await response.json();
@@ -121,7 +125,7 @@ export async function searchTickets(jql, opts = {}) {
121
125
  if (!response.ok) {
122
126
  let detail = '';
123
127
  try { const body = await response.json(); detail = (body.errorMessages || []).join('; '); } catch {}
124
- const err = new Error(`Jira API error ${response.status} (${response.statusText}) searching tickets${detail ? ': ' + detail : ''}`);
128
+ const err = new Error(`Jira API error ${response.status} searching tickets`);
125
129
  err.status = response.status;
126
130
  err.detail = detail;
127
131
  throw err;
@@ -159,7 +163,9 @@ export async function fetchTicket(ticketKey, opts = {}) {
159
163
  const response = await fetcher(url, fetchOpts);
160
164
 
161
165
  if (!response.ok) {
162
- throw new Error(`Jira API error ${response.status} (${response.statusText}) fetching ${ticketKey}`);
166
+ const err = new Error(`Jira API error ${response.status} fetching ${ticketKey}`);
167
+ err.status = response.status;
168
+ throw err;
163
169
  }
164
170
 
165
171
  const raw = await response.json();
@@ -47,7 +47,8 @@ export function readLedger({ configDir = DEFAULT_CONFIG_DIR, fsModule = _fs, sin
47
47
  return raw
48
48
  .split('\n')
49
49
  .filter(line => line.trim().length > 0)
50
- .map(line => JSON.parse(line))
50
+ .map(line => { try { return JSON.parse(line); } catch { return null; } })
51
+ .filter(record => record !== null)
51
52
  .filter(record => sinceMs === null || new Date(record.ts).getTime() >= sinceMs);
52
53
  }
53
54
 
@@ -5,10 +5,15 @@ import crypto from 'node:crypto';
5
5
  import { DEFAULT_CONFIG_DIR } from './config.mjs';
6
6
  import { createStyler } from './ansi.mjs';
7
7
 
8
+ // Mixed into the HMAC key so that knowing the license key alone is not sufficient
9
+ // to forge a valid signature — an attacker also needs this constant from the source.
10
+ export const LICENSE_HMAC_SALT = 'tl-lic-v1';
8
11
  export const LICENSE_TIERS = { free: 0, pro: 1, team: 2 };
9
12
  const LICENSE_FILE = 'license.json';
13
+ const LICENSE_SECRET_FILE = 'license-hmac-secret.json';
10
14
  const REVALIDATION_DAYS = 7; // attempt background revalidation after this many days
11
15
  const GRACE_DAYS = 30; // treat license as invalid if not revalidated within this window
16
+ const MS_PER_DAY = 86400000;
12
17
  const UPGRADE_URL = 'https://ticketlens.dev/pricing';
13
18
 
14
19
  const ANSI_RE_LIC = /\x1b\[[0-9;]*m|\x1b\]8;[^\x07]*\x07/g;
@@ -17,6 +22,42 @@ function padInner(str, width) {
17
22
  return str + ' '.repeat(Math.max(0, width - visLen(str)));
18
23
  }
19
24
 
25
+ function readHmacSecret(configDir) {
26
+ try {
27
+ const data = JSON.parse(fs.readFileSync(path.join(configDir, LICENSE_SECRET_FILE), 'utf8'));
28
+ return typeof data.secret === 'string' && data.secret.length === 64 ? data.secret : null;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ function readOrCreateHmacSecret(configDir) {
35
+ const existing = readHmacSecret(configDir);
36
+ if (existing) return existing;
37
+ fs.mkdirSync(configDir, { recursive: true });
38
+ const secret = crypto.randomBytes(32).toString('hex');
39
+ const secretPath = path.join(configDir, LICENSE_SECRET_FILE);
40
+ const tmp = `${secretPath}.${process.pid}.tmp`;
41
+ fs.writeFileSync(tmp, JSON.stringify({ secret }), { encoding: 'utf8', mode: 0o600 });
42
+ try {
43
+ // Atomic on POSIX — if we lost a concurrent race, the winner's file stays
44
+ fs.renameSync(tmp, secretPath);
45
+ } catch {
46
+ try { fs.unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
47
+ }
48
+ // Read back: we may have lost the race; use whatever secret is on disk
49
+ return readHmacSecret(configDir) ?? secret;
50
+ }
51
+
52
+ function verifyLicenseHmac(sig, payload, sigKey) {
53
+ const expected = crypto.createHmac('sha256', sigKey)
54
+ .update(JSON.stringify(payload)).digest('hex');
55
+ const sigBuf = Buffer.from(sig, 'hex');
56
+ const expBuf = Buffer.from(expected, 'hex');
57
+ if (sigBuf.length !== expBuf.length) return false;
58
+ return crypto.timingSafeEqual(sigBuf, expBuf);
59
+ }
60
+
20
61
  export function readLicense(configDir = DEFAULT_CONFIG_DIR) {
21
62
  const filePath = path.join(configDir, LICENSE_FILE);
22
63
  try {
@@ -24,9 +65,19 @@ export function readLicense(configDir = DEFAULT_CONFIG_DIR) {
24
65
  const { sig, ...payload } = data;
25
66
  // Legacy unsigned files are trusted; will be re-signed on next write
26
67
  if (!sig) return payload;
27
- const expected = crypto.createHmac('sha256', payload.key || '')
28
- .update(JSON.stringify(payload)).digest('hex');
29
- return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)) ? payload : null;
68
+
69
+ const machineSecret = readHmacSecret(configDir);
70
+ if (machineSecret) {
71
+ // Machine secret present — only accept signatures made with it
72
+ return verifyLicenseHmac(sig, payload, `${LICENSE_HMAC_SALT}:${machineSecret}`)
73
+ ? payload : null;
74
+ }
75
+
76
+ // No secret file yet — fall back to legacy key-based HMAC (backward compat).
77
+ // If valid, immediately re-sign with a machine secret to close the migration window.
78
+ if (!verifyLicenseHmac(sig, payload, `${LICENSE_HMAC_SALT}:${payload.key || ''}`)) return null;
79
+ try { writeLicense(payload, configDir); } catch { /* non-fatal — next call will retry */ }
80
+ return payload;
30
81
  } catch {
31
82
  return null;
32
83
  }
@@ -36,9 +87,10 @@ export function writeLicense(data, configDir = DEFAULT_CONFIG_DIR) {
36
87
  fs.mkdirSync(configDir, { recursive: true });
37
88
  const filePath = path.join(configDir, LICENSE_FILE);
38
89
  const { sig: _, ...payload } = data; // strip any existing sig before re-signing
39
- const mac = crypto.createHmac('sha256', payload.key || '')
90
+ const secret = readOrCreateHmacSecret(configDir);
91
+ const mac = crypto.createHmac('sha256', `${LICENSE_HMAC_SALT}:${secret}`)
40
92
  .update(JSON.stringify(payload)).digest('hex');
41
- fs.writeFileSync(filePath, JSON.stringify({ ...payload, sig: mac }), 'utf8');
93
+ fs.writeFileSync(filePath, JSON.stringify({ ...payload, sig: mac }), { encoding: 'utf8', mode: 0o600 });
42
94
  fs.chmodSync(filePath, 0o600);
43
95
  }
44
96
 
@@ -55,7 +107,7 @@ export function isLicensed(tier, configDir = DEFAULT_CONFIG_DIR) {
55
107
 
56
108
  // Offline grace period — reject if not revalidated within GRACE_DAYS
57
109
  if (license.validatedAt) {
58
- const daysSince = (Date.now() - new Date(license.validatedAt)) / 86400000;
110
+ const daysSince = (Date.now() - new Date(license.validatedAt)) / MS_PER_DAY;
59
111
  if (daysSince > GRACE_DAYS) return false;
60
112
  }
61
113
 
@@ -71,7 +123,7 @@ export function revalidateIfStale(opts = {}) {
71
123
  const { configDir = DEFAULT_CONFIG_DIR, fetcher = globalThis.fetch } = opts;
72
124
  const license = readLicense(configDir);
73
125
  if (!license?.key || !license?.validatedAt) return;
74
- const daysSince = (Date.now() - new Date(license.validatedAt)) / 86400000;
126
+ const daysSince = (Date.now() - new Date(license.validatedAt)) / MS_PER_DAY;
75
127
  if (daysSince < REVALIDATION_DAYS) return;
76
128
  revalidateLicense({ configDir, fetcher }).catch(() => {});
77
129
  }
@@ -103,14 +155,17 @@ export function showUpgradePrompt(requiredTier, featureFlag, { stream = process.
103
155
  const LEMONSQUEEZY_ACTIVATE_URL = 'https://api.lemonsqueezy.com/v1/licenses/activate';
104
156
  const LEMONSQUEEZY_VALIDATE_URL = 'https://api.lemonsqueezy.com/v1/licenses/validate';
105
157
 
106
- function resolveActivateUrl() {
158
+ function resolveLicenseUrl(action, fallback) {
107
159
  const base = process.env.TICKETLENS_API_URL;
108
- return base ? `${base.replace(/\/$/, '')}/v1/licenses/activate` : LEMONSQUEEZY_ACTIVATE_URL;
160
+ return base ? `${base.replace(/\/$/, '')}/v1/licenses/${action}` : fallback;
161
+ }
162
+
163
+ function resolveActivateUrl() {
164
+ return resolveLicenseUrl('activate', LEMONSQUEEZY_ACTIVATE_URL);
109
165
  }
110
166
 
111
167
  function resolveValidateUrl() {
112
- const base = process.env.TICKETLENS_API_URL;
113
- return base ? `${base.replace(/\/$/, '')}/v1/licenses/validate` : LEMONSQUEEZY_VALIDATE_URL;
168
+ return resolveLicenseUrl('validate', LEMONSQUEEZY_VALIDATE_URL);
114
169
  }
115
170
 
116
171
  function extractTier(meta) {
@@ -3,7 +3,7 @@
3
3
  * Resolution order: --profile flag → ticket prefix match → default profile → env vars
4
4
  */
5
5
 
6
- import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync, chmodSync } from 'node:fs';
6
+ import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
8
  import { homedir } from 'node:os';
9
9
  import { DEFAULT_CONFIG_DIR } from './config.mjs';
@@ -51,8 +51,7 @@ export function saveDefault(name, configDir = DEFAULT_CONFIG_DIR) {
51
51
  const profilesPath = join(configDir, 'profiles.json');
52
52
  const config = loadProfiles(configDir) || { profiles: {} };
53
53
  config.default = name;
54
- writeFileSync(profilesPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
55
- chmodSync(profilesPath, 0o600);
54
+ writeFileSync(profilesPath, JSON.stringify(config, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
56
55
  invalidateProfilesCache(configDir);
57
56
  }
58
57
 
@@ -61,14 +60,12 @@ export function saveProfile(name, profileData, credData, configDir = DEFAULT_CON
61
60
  const profilesPath = join(configDir, 'profiles.json');
62
61
  const config = loadProfiles(configDir) || { profiles: {} };
63
62
  config.profiles[name] = profileData;
64
- writeFileSync(profilesPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
65
- chmodSync(profilesPath, 0o600);
63
+ writeFileSync(profilesPath, JSON.stringify(config, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
66
64
  if (credData && Object.keys(credData).length > 0) {
67
65
  const credPath = join(configDir, 'credentials.json');
68
66
  const creds = loadCredentials(configDir);
69
67
  creds[name] = credData;
70
- writeFileSync(credPath, JSON.stringify(creds, null, 2) + '\n', 'utf8');
71
- chmodSync(credPath, 0o600);
68
+ writeFileSync(credPath, JSON.stringify(creds, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
72
69
  }
73
70
  invalidateProfilesCache(configDir);
74
71
  }
@@ -81,8 +78,7 @@ export function deleteProfile(name, configDir = DEFAULT_CONFIG_DIR) {
81
78
  delete config.profiles[name];
82
79
  if (config.default === name) delete config.default;
83
80
 
84
- writeFileSync(profilesPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
85
- chmodSync(profilesPath, 0o600);
81
+ writeFileSync(profilesPath, JSON.stringify(config, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
86
82
 
87
83
  // Remove credentials entry
88
84
  const credPath = join(configDir, 'credentials.json');
@@ -90,8 +86,7 @@ export function deleteProfile(name, configDir = DEFAULT_CONFIG_DIR) {
90
86
  const creds = loadCredentials(configDir);
91
87
  if (creds[name]) {
92
88
  delete creds[name];
93
- writeFileSync(credPath, JSON.stringify(creds, null, 2) + '\n', 'utf8');
94
- chmodSync(credPath, 0o600);
89
+ writeFileSync(credPath, JSON.stringify(creds, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
95
90
  }
96
91
  }
97
92
 
@@ -103,9 +98,13 @@ export function loadProfiles(configDir = DEFAULT_CONFIG_DIR) {
103
98
  if (_profilesCache.has(configDir)) return _profilesCache.get(configDir);
104
99
  const profilesPath = join(configDir, 'profiles.json');
105
100
  if (!existsSync(profilesPath)) return null;
106
- const data = JSON.parse(readFileSync(profilesPath, 'utf8'));
107
- _profilesCache.set(configDir, data);
108
- return data;
101
+ try {
102
+ const data = JSON.parse(readFileSync(profilesPath, 'utf8'));
103
+ _profilesCache.set(configDir, data);
104
+ return data;
105
+ } catch {
106
+ return null;
107
+ }
109
108
  }
110
109
 
111
110
  export function loadCredentials(configDir = DEFAULT_CONFIG_DIR) {
@@ -115,9 +114,14 @@ export function loadCredentials(configDir = DEFAULT_CONFIG_DIR) {
115
114
  _credentialsCache.set(configDir, {});
116
115
  return {};
117
116
  }
118
- const data = JSON.parse(readFileSync(credPath, 'utf8'));
119
- _credentialsCache.set(configDir, data);
120
- return data;
117
+ try {
118
+ const data = JSON.parse(readFileSync(credPath, 'utf8'));
119
+ _credentialsCache.set(configDir, data);
120
+ return data;
121
+ } catch {
122
+ _credentialsCache.set(configDir, {});
123
+ return {};
124
+ }
121
125
  }
122
126
 
123
127
  export function saveCredentialKey(key, value, configDir = DEFAULT_CONFIG_DIR) {
@@ -129,8 +133,7 @@ export function saveCredentialKey(key, value, configDir = DEFAULT_CONFIG_DIR) {
129
133
  } else {
130
134
  creds[key] = value;
131
135
  }
132
- writeFileSync(credPath, JSON.stringify(creds, null, 2) + '\n', 'utf8');
133
- chmodSync(credPath, 0o600);
136
+ writeFileSync(credPath, JSON.stringify(creds, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
134
137
  invalidateProfilesCache(configDir);
135
138
  }
136
139
 
@@ -1,12 +1,9 @@
1
1
  import { readLicense } from './license.mjs';
2
2
  import { formatCollisions } from './collision-reporter.mjs';
3
3
 
4
- const COLLISIONS_PATH = '/v1/triage/collisions';
5
- const DEFAULT_API_BASE = 'http://ticketlens.test';
4
+ import { apiBase, warnIfInsecure } from './api-utils.mjs';
6
5
 
7
- function apiBase() {
8
- return process.env?.TICKETLENS_API_URL ?? DEFAULT_API_BASE;
9
- }
6
+ const COLLISIONS_PATH = '/v1/triage/collisions';
10
7
 
11
8
  /**
12
9
  * Fetches cross-team branch collision data from the TicketLens API and prints results.
@@ -23,7 +20,9 @@ export async function runCollisions(args = [], opts = {}) {
23
20
  const plainFlag = args.includes('--plain');
24
21
  const fetcher = opts.fetcher ?? globalThis.fetch;
25
22
  const print = opts.print ?? ((s) => process.stdout.write(s));
23
+ const warn = opts.warn ?? ((s) => process.stderr.write(s));
26
24
  const readLicenseFn = opts.readLicenseFn ?? (() => readLicense());
25
+ warnIfInsecure(apiBase(), warn);
27
26
 
28
27
  const licenseKey = readLicenseFn()?.key ?? null;
29
28
  if (!licenseKey) {
@@ -34,11 +33,18 @@ export async function runCollisions(args = [], opts = {}) {
34
33
  try {
35
34
  const res = await fetcher(`${apiBase()}${COLLISIONS_PATH}`, {
36
35
  headers: { Authorization: `Bearer ${licenseKey}` },
36
+ signal: AbortSignal.timeout(15_000),
37
37
  });
38
38
 
39
39
  if (!res.ok) {
40
- if (res.status === 401) { print('✗ Invalid license key\n'); return { ok: false, status: 401 }; }
41
- if (res.status === 403) { print('✗ collisions requires a Team license\n'); return { ok: false, status: 403 }; }
40
+ if (res.status === 401) {
41
+ print('✗ Invalid license key\n');
42
+ return { ok: false, status: 401 };
43
+ }
44
+ if (res.status === 403) {
45
+ print('✗ collisions requires a Team license\n');
46
+ return { ok: false, status: 403 };
47
+ }
42
48
  print(`⚠ Failed to fetch collisions (${res.status})\n`);
43
49
  return { ok: false, status: res.status };
44
50
  }
@@ -5,12 +5,10 @@
5
5
  */
6
6
 
7
7
  import * as defaultFs from 'node:fs';
8
+ import { homedir } from 'node:os';
8
9
  import { join } from 'node:path';
9
10
 
10
- const DEFAULT_CONFIG_DIR = join(
11
- process.env.HOME ?? process.env.USERPROFILE ?? '',
12
- '.ticketlens'
13
- );
11
+ const DEFAULT_CONFIG_DIR = join(homedir(), '.ticketlens');
14
12
 
15
13
  const URGENCY_ORDER = { 'needs-response': 0, 'aging': 1, 'clear': 2 };
16
14
 
@@ -3,13 +3,13 @@
3
3
  * All errors are caught — push failure must never break triage output.
4
4
  */
5
5
 
6
- const PUSH_PATH = '/v1/triage/push';
7
- // Local dev: http://ticketlens.test — production override via TICKETLENS_API_URL env var
8
- const DEFAULT_API_BASE = 'http://ticketlens.test';
6
+ import { readLedger } from './ledger.mjs';
7
+ import { isLicensed } from './license.mjs';
8
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
9
9
 
10
- function apiBase() {
11
- return process.env?.TICKETLENS_API_URL ?? DEFAULT_API_BASE;
12
- }
10
+ import { apiBase, warnIfInsecure } from './api-utils.mjs';
11
+
12
+ const PUSH_PATH = '/v1/triage/push';
13
13
 
14
14
  // Derive console queue URL from API base:
15
15
  // http://api.ticketlens.test → http://ticketlens.test/console/queue
@@ -64,16 +64,44 @@ export async function pushTriageSnapshot({
64
64
  gitBranches,
65
65
  fetcher = globalThis.fetch,
66
66
  print = (s) => process.stdout.write(s),
67
+ warn = (s) => process.stderr.write(s),
68
+ readLedgerFn = readLedger,
69
+ isLicensedFn = isLicensed,
70
+ configDir = DEFAULT_CONFIG_DIR,
67
71
  } = {}) {
72
+ warnIfInsecure(apiBase(), warn);
68
73
  if (!licenseKey) {
69
74
  print('✗ --push requires an active Team license (ticketlens activate <key>)\n');
70
75
  return { ok: false };
71
76
  }
72
77
 
78
+ let tickets = sorted.map(t => buildTicketPayload(t, rawTicketMap, baseUrl));
79
+
80
+ if (isLicensedFn('pro', configDir)) {
81
+ try {
82
+ const latestByKey = new Map();
83
+ for (const entry of readLedgerFn({ configDir })) {
84
+ const prev = latestByKey.get(entry.ticketKey);
85
+ if (!prev || entry.ts > prev.ts) latestByKey.set(entry.ticketKey, entry);
86
+ }
87
+ tickets = tickets.map(t => {
88
+ const entry = latestByKey.get(t.key);
89
+ if (!entry) return t;
90
+ return {
91
+ ...t,
92
+ compliance_coverage: entry.coverage ?? null,
93
+ compliance_status: (entry.missing?.length ?? 0) === 0 ? 'pass' : 'gap',
94
+ };
95
+ });
96
+ } catch {
97
+ // non-fatal — ledger errors must not break triage push
98
+ }
99
+ }
100
+
73
101
  const payload = {
74
102
  profile: String(profile ?? 'default').slice(0, 100),
75
103
  captured_at: capturedAt ?? new Date().toISOString(),
76
- tickets: sorted.map(t => buildTicketPayload(t, rawTicketMap, baseUrl)),
104
+ tickets,
77
105
  ...(gitBranches != null && { git_branches: gitBranches }),
78
106
  };
79
107
 
@@ -85,6 +113,7 @@ export async function pushTriageSnapshot({
85
113
  'Authorization': `Bearer ${licenseKey}`,
86
114
  },
87
115
  body: JSON.stringify(payload),
116
+ signal: AbortSignal.timeout(15_000),
88
117
  });
89
118
 
90
119
  if (res.ok) {
@@ -3,12 +3,9 @@
3
3
  * Errors are non-fatal — share failure must never suppress triage output.
4
4
  */
5
5
 
6
- const SHARE_PATH = '/v1/triage/share';
7
- const DEFAULT_API_BASE = 'http://ticketlens.test';
6
+ import { apiBase, warnIfInsecure } from './api-utils.mjs';
8
7
 
9
- function apiBase() {
10
- return process.env?.TICKETLENS_API_URL ?? DEFAULT_API_BASE;
11
- }
8
+ const SHARE_PATH = '/v1/triage/share';
12
9
 
13
10
  function buildTicketPayload(scored, rawMap, baseUrl) {
14
11
  const raw = rawMap?.get(scored.ticketKey);
@@ -47,7 +44,9 @@ export async function shareTriageSnapshot({
47
44
  capturedAt,
48
45
  fetcher = globalThis.fetch,
49
46
  print = (s) => process.stdout.write(s),
47
+ warn = (s) => process.stderr.write(s),
50
48
  } = {}) {
49
+ warnIfInsecure(apiBase(), warn);
51
50
  if (!licenseKey) {
52
51
  print('✗ --share requires an active Team license (ticketlens activate <key>)\n');
53
52
  return { ok: false };
@@ -67,6 +66,7 @@ export async function shareTriageSnapshot({
67
66
  'Authorization': `Bearer ${licenseKey}`,
68
67
  },
69
68
  body: JSON.stringify(payload),
69
+ signal: AbortSignal.timeout(15_000),
70
70
  });
71
71
 
72
72
  if (res.ok) {