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 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
- - **"TicketLens for PRs"** (`ticketlens review`) — assembles a code-review context brief from your current branch: extracts linked ticket keys from the branch name and commits, fetches each ticket, and outputs a structured brief with branch, changed files, and ticket context. Styled ANSI output, spinner, flag validation with typo hints, and base-branch safety check
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
 
@@ -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.once('data', char => {
207
- process.stdin.setRawMode(false);
208
- process.stdin.pause();
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.4.1",
3
+ "version": "0.4.4",
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
+ }
@@ -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, 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
  }
@@ -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 = 'ledger.jsonl';
12
- const KEY_FILE = 'ledger-key';
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
- 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 };