ticketlens 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.4.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.4.0 -->
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.
@@ -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
+ }
@@ -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
  }
@@ -5,13 +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
- export // Mixed into the HMAC key so that knowing the license key alone is not sufficient
8
+ // Mixed into the HMAC key so that knowing the license key alone is not sufficient
9
9
  // to forge a valid signature — an attacker also needs this constant from the source.
10
- const LICENSE_HMAC_SALT = 'tl-lic-v1';
10
+ export const LICENSE_HMAC_SALT = 'tl-lic-v1';
11
11
  export const LICENSE_TIERS = { free: 0, pro: 1, team: 2 };
12
12
  const LICENSE_FILE = 'license.json';
13
+ const LICENSE_SECRET_FILE = 'license-hmac-secret.json';
13
14
  const REVALIDATION_DAYS = 7; // attempt background revalidation after this many days
14
15
  const GRACE_DAYS = 30; // treat license as invalid if not revalidated within this window
16
+ const MS_PER_DAY = 86400000;
15
17
  const UPGRADE_URL = 'https://ticketlens.dev/pricing';
16
18
 
17
19
  const ANSI_RE_LIC = /\x1b\[[0-9;]*m|\x1b\]8;[^\x07]*\x07/g;
@@ -20,6 +22,42 @@ function padInner(str, width) {
20
22
  return str + ' '.repeat(Math.max(0, width - visLen(str)));
21
23
  }
22
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
+
23
61
  export function readLicense(configDir = DEFAULT_CONFIG_DIR) {
24
62
  const filePath = path.join(configDir, LICENSE_FILE);
25
63
  try {
@@ -27,14 +65,19 @@ export function readLicense(configDir = DEFAULT_CONFIG_DIR) {
27
65
  const { sig, ...payload } = data;
28
66
  // Legacy unsigned files are trusted; will be re-signed on next write
29
67
  if (!sig) return payload;
30
- const sigKey = `${LICENSE_HMAC_SALT}:${payload.key || ''}`;
31
- const expected = crypto.createHmac('sha256', sigKey)
32
- .update(JSON.stringify(payload)).digest('hex');
33
- const sigBuf = Buffer.from(sig, 'hex');
34
- const expBuf = Buffer.from(expected, 'hex');
35
- // timingSafeEqual requires equal-length buffers; unequal lengths mean tampered sig
36
- if (sigBuf.length !== expBuf.length) return null;
37
- return crypto.timingSafeEqual(sigBuf, expBuf) ? 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;
38
81
  } catch {
39
82
  return null;
40
83
  }
@@ -44,10 +87,10 @@ export function writeLicense(data, configDir = DEFAULT_CONFIG_DIR) {
44
87
  fs.mkdirSync(configDir, { recursive: true });
45
88
  const filePath = path.join(configDir, LICENSE_FILE);
46
89
  const { sig: _, ...payload } = data; // strip any existing sig before re-signing
47
- const sigKey = `${LICENSE_HMAC_SALT}:${payload.key || ''}`;
48
- const mac = crypto.createHmac('sha256', sigKey)
90
+ const secret = readOrCreateHmacSecret(configDir);
91
+ const mac = crypto.createHmac('sha256', `${LICENSE_HMAC_SALT}:${secret}`)
49
92
  .update(JSON.stringify(payload)).digest('hex');
50
- fs.writeFileSync(filePath, JSON.stringify({ ...payload, sig: mac }), 'utf8');
93
+ fs.writeFileSync(filePath, JSON.stringify({ ...payload, sig: mac }), { encoding: 'utf8', mode: 0o600 });
51
94
  fs.chmodSync(filePath, 0o600);
52
95
  }
53
96
 
@@ -64,7 +107,7 @@ export function isLicensed(tier, configDir = DEFAULT_CONFIG_DIR) {
64
107
 
65
108
  // Offline grace period — reject if not revalidated within GRACE_DAYS
66
109
  if (license.validatedAt) {
67
- const daysSince = (Date.now() - new Date(license.validatedAt)) / 86400000;
110
+ const daysSince = (Date.now() - new Date(license.validatedAt)) / MS_PER_DAY;
68
111
  if (daysSince > GRACE_DAYS) return false;
69
112
  }
70
113
 
@@ -80,7 +123,7 @@ export function revalidateIfStale(opts = {}) {
80
123
  const { configDir = DEFAULT_CONFIG_DIR, fetcher = globalThis.fetch } = opts;
81
124
  const license = readLicense(configDir);
82
125
  if (!license?.key || !license?.validatedAt) return;
83
- const daysSince = (Date.now() - new Date(license.validatedAt)) / 86400000;
126
+ const daysSince = (Date.now() - new Date(license.validatedAt)) / MS_PER_DAY;
84
127
  if (daysSince < REVALIDATION_DAYS) return;
85
128
  revalidateLicense({ configDir, fetcher }).catch(() => {});
86
129
  }
@@ -112,14 +155,17 @@ export function showUpgradePrompt(requiredTier, featureFlag, { stream = process.
112
155
  const LEMONSQUEEZY_ACTIVATE_URL = 'https://api.lemonsqueezy.com/v1/licenses/activate';
113
156
  const LEMONSQUEEZY_VALIDATE_URL = 'https://api.lemonsqueezy.com/v1/licenses/validate';
114
157
 
115
- function resolveActivateUrl() {
158
+ function resolveLicenseUrl(action, fallback) {
116
159
  const base = process.env.TICKETLENS_API_URL;
117
- 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);
118
165
  }
119
166
 
120
167
  function resolveValidateUrl() {
121
- const base = process.env.TICKETLENS_API_URL;
122
- return base ? `${base.replace(/\/$/, '')}/v1/licenses/validate` : LEMONSQUEEZY_VALIDATE_URL;
168
+ return resolveLicenseUrl('validate', LEMONSQUEEZY_VALIDATE_URL);
123
169
  }
124
170
 
125
171
  function extractTier(meta) {
@@ -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) {
@@ -38,8 +37,14 @@ export async function runCollisions(args = [], opts = {}) {
38
37
  });
39
38
 
40
39
  if (!res.ok) {
41
- if (res.status === 401) { print('✗ Invalid license key\n'); return { ok: false, status: 401 }; }
42
- 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
+ }
43
48
  print(`⚠ Failed to fetch collisions (${res.status})\n`);
44
49
  return { ok: false, status: res.status };
45
50
  }
@@ -7,13 +7,9 @@ import { readLedger } from './ledger.mjs';
7
7
  import { isLicensed } from './license.mjs';
8
8
  import { DEFAULT_CONFIG_DIR } from './config.mjs';
9
9
 
10
- const PUSH_PATH = '/v1/triage/push';
11
- // Local dev: http://ticketlens.test — production override via TICKETLENS_API_URL env var
12
- const DEFAULT_API_BASE = 'http://ticketlens.test';
10
+ import { apiBase, warnIfInsecure } from './api-utils.mjs';
13
11
 
14
- function apiBase() {
15
- return process.env?.TICKETLENS_API_URL ?? DEFAULT_API_BASE;
16
- }
12
+ const PUSH_PATH = '/v1/triage/push';
17
13
 
18
14
  // Derive console queue URL from API base:
19
15
  // http://api.ticketlens.test → http://ticketlens.test/console/queue
@@ -68,10 +64,12 @@ export async function pushTriageSnapshot({
68
64
  gitBranches,
69
65
  fetcher = globalThis.fetch,
70
66
  print = (s) => process.stdout.write(s),
67
+ warn = (s) => process.stderr.write(s),
71
68
  readLedgerFn = readLedger,
72
69
  isLicensedFn = isLicensed,
73
70
  configDir = DEFAULT_CONFIG_DIR,
74
71
  } = {}) {
72
+ warnIfInsecure(apiBase(), warn);
75
73
  if (!licenseKey) {
76
74
  print('✗ --push requires an active Team license (ticketlens activate <key>)\n');
77
75
  return { ok: false };
@@ -81,9 +79,8 @@ export async function pushTriageSnapshot({
81
79
 
82
80
  if (isLicensedFn('pro', configDir)) {
83
81
  try {
84
- const entries = readLedgerFn({ configDir });
85
82
  const latestByKey = new Map();
86
- for (const entry of entries) {
83
+ for (const entry of readLedgerFn({ configDir })) {
87
84
  const prev = latestByKey.get(entry.ticketKey);
88
85
  if (!prev || entry.ts > prev.ts) latestByKey.set(entry.ticketKey, entry);
89
86
  }
@@ -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 };