ticketlens 0.4.1 → 0.4.4
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 +5 -3
- package/bin/ticketlens.mjs +8 -4
- 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/brief-cache.mjs +9 -0
- package/skills/jtb/scripts/lib/cli-auth.mjs +4 -3
- package/skills/jtb/scripts/lib/ledger.mjs +21 -2
- 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/README.md
CHANGED
|
@@ -587,7 +587,7 @@ ticketlens CNV1-2 --summarize # AI summary via your own API key (BYOK
|
|
|
587
587
|
ticketlens CNV1-2 --summarize --cloud # AI summary via TicketLens API (no local key needed)
|
|
588
588
|
ticketlens CNV1-2 --handoff # AI handoff brief from the ticket's comment thread (BYOK)
|
|
589
589
|
ticketlens CNV1-2 --handoff --cloud # AI handoff brief via TicketLens API
|
|
590
|
-
ticketlens CNV1-2 --compliance # Check ticket requirements against local diff [Free 3/mo]
|
|
590
|
+
ticketlens CNV1-2 --compliance # Check ticket requirements against local diff [Pro/Free 3/mo]
|
|
591
591
|
ticketlens triage --stale=3 # Custom stale threshold (default is 5)
|
|
592
592
|
ticketlens triage --digest # POST scored triage results to digest endpoint
|
|
593
593
|
ticketlens schedule # Set up a scheduled daily digest
|
|
@@ -735,11 +735,13 @@ npm test
|
|
|
735
735
|
See [ROADMAP.md](ROADMAP.md) for the full plan.
|
|
736
736
|
|
|
737
737
|
Recently shipped:
|
|
738
|
-
- **
|
|
738
|
+
- **Collision detection** (`ticketlens collisions`) — shows which files your branch shares with teammates' in-flight branches; `--push` auto-sends git branch data to the team snapshot. Team tier
|
|
739
|
+
- **Shareable triage snapshot** (`ticketlens triage --share`) — generates a 24h signed URL; recipients open it in any browser, no account needed. Team tier
|
|
740
|
+
- **Compliance push** (`ticketlens triage --push`) — enriches the team snapshot with per-ticket compliance status and coverage from the local ledger. Pro tier
|
|
741
|
+
- **"TicketLens for PRs"** (`ticketlens review`) — assembles a code-review context brief from your current branch: linked tickets, changed files, and ticket context in one brief
|
|
739
742
|
- **Confluence pages** — linked Confluence pages fetched automatically and included in the brief; origin-validated, non-fatal, capped at 10 pages
|
|
740
743
|
- **Linear support** — `ticketlens init` → Linear; connects via GraphQL API key, fetches tickets, triage, and statuses
|
|
741
744
|
- **GitHub Issues support** — `ticketlens init` → GitHub Issues; PAT-based, same normalized ticket shape
|
|
742
|
-
- **Tracker-aware config** — `ticketlens config` shows the right labels and skips irrelevant prompts per tracker type; always re-validates the connection
|
|
743
745
|
|
|
744
746
|
---
|
|
745
747
|
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -203,13 +203,17 @@ switch (command) {
|
|
|
203
203
|
process.stdin.setRawMode(true);
|
|
204
204
|
process.stdin.resume();
|
|
205
205
|
process.stdin.setEncoding('utf8');
|
|
206
|
-
process.stdin.
|
|
207
|
-
|
|
208
|
-
process.stdin.
|
|
206
|
+
const cleanup = () => { process.stdin.setRawMode(false); process.stdin.pause(); };
|
|
207
|
+
const onData = char => {
|
|
208
|
+
process.stdin.removeListener('end', onEnd);
|
|
209
|
+
cleanup();
|
|
209
210
|
process.stderr.write('\n');
|
|
210
211
|
if (char === '\x03') process.exit(0);
|
|
211
212
|
res(char === 'y' || char === 'Y');
|
|
212
|
-
}
|
|
213
|
+
};
|
|
214
|
+
const onEnd = () => { process.stdin.removeListener('data', onData); cleanup(); res(false); };
|
|
215
|
+
process.stdin.once('data', onData);
|
|
216
|
+
process.stdin.once('end', onEnd);
|
|
213
217
|
});
|
|
214
218
|
if (!answer) {
|
|
215
219
|
process.stderr.write(` ${s.dim('Cancelled.')}\n`);
|
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
|
+
}
|
|
@@ -82,11 +82,19 @@ export function clearBriefCache(ticketKey, profileName, configDir = DEFAULT_CONF
|
|
|
82
82
|
} catch { /* non-fatal */ }
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
// Memoize getBriefCacheEntries per configDir within a single process invocation.
|
|
86
|
+
// 5s TTL is well beyond the lifetime of any CLI run; prevents repeated stat+read+parse
|
|
87
|
+
// when multiple callers (cache list, cache prune, cache status) hit the same directory.
|
|
88
|
+
const _entriesCache = new Map(); // configDir → { entries, time }
|
|
89
|
+
const _ENTRIES_TTL_MS = 5_000;
|
|
90
|
+
|
|
85
91
|
/**
|
|
86
92
|
* Returns all brief cache entries across all profiles.
|
|
87
93
|
* Each entry: { profileName, ticketKey, filePath, size, mtimeMs, fetchedAt, depth }
|
|
88
94
|
*/
|
|
89
95
|
export function getBriefCacheEntries(configDir = DEFAULT_CONFIG_DIR) {
|
|
96
|
+
const cached = _entriesCache.get(configDir);
|
|
97
|
+
if (cached && Date.now() - cached.time < _ENTRIES_TTL_MS) return cached.entries;
|
|
90
98
|
const cacheDir = path.join(configDir, 'cache');
|
|
91
99
|
if (!fs.existsSync(cacheDir)) return [];
|
|
92
100
|
|
|
@@ -120,6 +128,7 @@ export function getBriefCacheEntries(configDir = DEFAULT_CONFIG_DIR) {
|
|
|
120
128
|
}
|
|
121
129
|
}
|
|
122
130
|
|
|
131
|
+
_entriesCache.set(configDir, { entries, time: Date.now() });
|
|
123
132
|
return entries;
|
|
124
133
|
}
|
|
125
134
|
|
|
@@ -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
|
}
|
|
@@ -8,8 +8,9 @@ import { join } from 'node:path';
|
|
|
8
8
|
import { createHmac, randomBytes } from 'node:crypto';
|
|
9
9
|
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
10
10
|
|
|
11
|
-
const LEDGER_FILE
|
|
12
|
-
const KEY_FILE
|
|
11
|
+
const LEDGER_FILE = 'ledger.jsonl';
|
|
12
|
+
const KEY_FILE = 'ledger-key';
|
|
13
|
+
const MAX_LEDGER_RECORDS = 5_000;
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Append one compliance record to ledger.jsonl.
|
|
@@ -25,6 +26,7 @@ export function appendLedger(record, { configDir = DEFAULT_CONFIG_DIR, fsModule
|
|
|
25
26
|
|
|
26
27
|
const line = JSON.stringify({ ts: new Date().toISOString(), ...record }) + '\n';
|
|
27
28
|
fsModule.appendFileSync(join(configDir, LEDGER_FILE), line, 'utf8');
|
|
29
|
+
_rotateIfNeeded(configDir, fsModule);
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
/**
|
|
@@ -84,6 +86,23 @@ export function exportLedger(format, { configDir = DEFAULT_CONFIG_DIR, fsModule
|
|
|
84
86
|
|
|
85
87
|
// ── Internal helpers ─────────────────────────────────────────────────────────
|
|
86
88
|
|
|
89
|
+
function _rotateIfNeeded(configDir, fsModule) {
|
|
90
|
+
const ledgerPath = join(configDir, LEDGER_FILE);
|
|
91
|
+
// Cheap size check first — each JSONL record averages ~180 bytes, so 5000 records ≈ 900 KB.
|
|
92
|
+
// Skip the full read when the file is well under the threshold.
|
|
93
|
+
try {
|
|
94
|
+
if (fsModule.statSync(ledgerPath).size < 900_000) return;
|
|
95
|
+
} catch { return; }
|
|
96
|
+
|
|
97
|
+
let raw;
|
|
98
|
+
try { raw = fsModule.readFileSync(ledgerPath, 'utf8'); } catch { return; }
|
|
99
|
+
|
|
100
|
+
const lines = raw.split('\n').filter(l => l.trim().length > 0);
|
|
101
|
+
if (lines.length > MAX_LEDGER_RECORDS) {
|
|
102
|
+
fsModule.writeFileSync(ledgerPath, lines.slice(-MAX_LEDGER_RECORDS).join('\n') + '\n', 'utf8');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
87
106
|
function _getOrCreateKey(configDir, fsModule) {
|
|
88
107
|
const keyPath = join(configDir, KEY_FILE);
|
|
89
108
|
try {
|
|
@@ -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 };
|