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 +1 -1
- package/skills/jtb/SKILL.md +1 -1
- package/skills/jtb/scripts/lib/api-utils.mjs +20 -0
- package/skills/jtb/scripts/lib/cli-auth.mjs +4 -3
- package/skills/jtb/scripts/lib/license.mjs +65 -19
- package/skills/jtb/scripts/lib/run-collisions.mjs +12 -7
- package/skills/jtb/scripts/lib/triage-push.mjs +5 -8
- package/skills/jtb/scripts/lib/triage-share.mjs +4 -5
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.4.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
48
|
-
const mac = crypto.createHmac('sha256',
|
|
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)) /
|
|
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)) /
|
|
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
|
|
158
|
+
function resolveLicenseUrl(action, fallback) {
|
|
116
159
|
const base = process.env.TICKETLENS_API_URL;
|
|
117
|
-
return base ? `${base.replace(/\/$/, '')}/v1/licenses
|
|
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
|
-
|
|
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
|
-
|
|
5
|
-
const DEFAULT_API_BASE = 'http://ticketlens.test';
|
|
4
|
+
import { apiBase, warnIfInsecure } from './api-utils.mjs';
|
|
6
5
|
|
|
7
|
-
|
|
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) {
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
7
|
-
const DEFAULT_API_BASE = 'http://ticketlens.test';
|
|
6
|
+
import { apiBase, warnIfInsecure } from './api-utils.mjs';
|
|
8
7
|
|
|
9
|
-
|
|
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 };
|