thumbgate 1.6.0 → 1.8.0

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.
@@ -15,12 +15,14 @@
15
15
  * address, and a functional unsubscribe method.
16
16
  */
17
17
 
18
+ const dns = require('node:dns').promises;
19
+
18
20
  const PRODUCT_NAME = 'ThumbGate Pro';
19
21
  const DASHBOARD_URL = 'https://thumbgate-production.up.railway.app/dashboard';
20
- const SUPPORT_EMAIL = 'hello@thumbgate.app';
22
+ const DEFAULT_CONTACT_EMAIL = 'igor.ganapolsky@gmail.com';
21
23
  const DEFAULT_FROM = 'onboarding@resend.dev';
22
- const DEFAULT_REPLY_TO = 'hello@thumbgate.app';
23
- const DEFAULT_UNSUBSCRIBE_EMAIL = 'unsubscribe@thumbgate.app';
24
+ const DEFAULT_REPLY_TO = DEFAULT_CONTACT_EMAIL;
25
+ const DEFAULT_UNSUBSCRIBE_EMAIL = DEFAULT_CONTACT_EMAIL;
24
26
  const DEFAULT_BUSINESS_NAME = 'Max Smith KDP LLC';
25
27
  // CAN-SPAM requires a physical mailing address. Override via THUMBGATE_BUSINESS_ADDRESS.
26
28
  const DEFAULT_BUSINESS_ADDRESS = '2261 Market Street #4242, San Francisco, CA 94114';
@@ -28,9 +30,23 @@ const DEFAULT_BUSINESS_ADDRESS = '2261 Market Street #4242, San Francisco, CA 94
28
30
  const BRAND_MARK_URL = 'https://thumbgate-production.up.railway.app/thumbgate-icon.png';
29
31
  const RESEND_ENDPOINT = 'https://api.resend.com/emails';
30
32
  const TRIAL_LENGTH_DAYS = 7;
33
+ const SENDER_DNS_CACHE_MS = 10 * 60 * 1000;
34
+ // Bounded to RFC 5321 limits (local-part ≤ 64, domain ≤ 255) to prevent
35
+ // super-linear backtracking on malformed input (Sonar javascript:S5852).
36
+ const ANGLE_EMAIL_RE = /<([^<>@\s]{1,64}@[^<>@\s]{1,255})>/;
37
+ const BARE_EMAIL_RE = /([^\s<>@]{1,64}@[^\s<>@]{1,255})/;
38
+ const DKIM_PUBLIC_KEY_RE = /^p=/i;
39
+ const AMAZON_SES_MX_RE = /feedback-smtp\..*amazonaws\.com\.?$/i;
40
+ const AMAZON_SES_SPF_RE = /include:amazonses\.com/i;
41
+ const TRAILING_EMAIL_DOMAIN_PUNCTUATION = new Set(['>', ')', ',', '.', ';']);
42
+ const senderDnsCache = new Map();
31
43
 
32
44
  function getApiKey() {
33
- return process.env.RESEND_API_KEY || '';
45
+ // Accept both `RESEND_API_KEY` (Railway default, matches provider docs) and
46
+ // the `THUMBGATE_`-prefixed variant that `scripts/billing.js` already honors.
47
+ // Keeping the two readers in sync prevents a silent "skipped: no_api_key"
48
+ // regression if an operator sets only the prefixed name.
49
+ return process.env.RESEND_API_KEY || process.env.THUMBGATE_RESEND_API_KEY || '';
34
50
  }
35
51
 
36
52
  function getFromAddress() {
@@ -41,6 +57,10 @@ function getReplyTo() {
41
57
  return process.env.THUMBGATE_TRIAL_EMAIL_REPLY_TO || DEFAULT_REPLY_TO;
42
58
  }
43
59
 
60
+ function getSupportEmail() {
61
+ return process.env.THUMBGATE_SUPPORT_EMAIL || getReplyTo();
62
+ }
63
+
44
64
  function getUnsubscribeEmail() {
45
65
  return process.env.THUMBGATE_UNSUBSCRIBE_EMAIL || DEFAULT_UNSUBSCRIBE_EMAIL;
46
66
  }
@@ -57,32 +77,193 @@ function isNonEmptyString(value) {
57
77
  return typeof value === 'string' && value.trim().length > 0;
58
78
  }
59
79
 
60
- /**
61
- * Low-level send. Posts to the Resend API or no-ops when RESEND_API_KEY is
62
- * missing. Never throws on network errors; returns a structured result instead.
63
- */
64
- async function sendEmail({ to, subject, html, text, from, replyTo, fetchImpl } = {}) {
80
+ function isTrueEnv(value) {
81
+ return /^(1|true|yes|on)$/i.test(String(value || '').trim());
82
+ }
83
+
84
+ function extractEmailAddress(value) {
85
+ const text = String(value || '').trim();
86
+ const angleMatch = ANGLE_EMAIL_RE.exec(text);
87
+ if (angleMatch) return angleMatch[1];
88
+ const bareMatch = BARE_EMAIL_RE.exec(text);
89
+ return bareMatch ? bareMatch[1] : '';
90
+ }
91
+
92
+ function getEmailDomain(value) {
93
+ const email = extractEmailAddress(value);
94
+ const at = email.lastIndexOf('@');
95
+ if (at === -1) return '';
96
+ let domain = email.slice(at + 1).trim();
97
+ while (domain && TRAILING_EMAIL_DOMAIN_PUNCTUATION.has(domain.at(-1))) {
98
+ domain = domain.slice(0, -1);
99
+ }
100
+ return domain.toLowerCase();
101
+ }
102
+
103
+ function splitCsv(value) {
104
+ return String(value || '')
105
+ .split(',')
106
+ .map((part) => part.trim().toLowerCase())
107
+ .filter(Boolean);
108
+ }
109
+
110
+ function getVerifiedSenderDomains() {
111
+ return new Set(splitCsv(process.env.THUMBGATE_VERIFIED_SENDER_DOMAINS));
112
+ }
113
+
114
+ function flattenTxt(records) {
115
+ return (records || []).map((chunks) => Array.isArray(chunks) ? chunks.join('') : String(chunks));
116
+ }
117
+
118
+ function getCachedSenderDnsReadiness(cacheKey) {
119
+ if (!cacheKey) return null;
120
+ const cached = senderDnsCache.get(cacheKey);
121
+ return cached && cached.expiresAt > Date.now() ? cached.ready : null;
122
+ }
123
+
124
+ function setCachedSenderDnsReadiness(cacheKey, ready) {
125
+ if (cacheKey) senderDnsCache.set(cacheKey, { ready, expiresAt: Date.now() + SENDER_DNS_CACHE_MS });
126
+ return ready;
127
+ }
128
+
129
+ async function readResendDnsRecords(domain, resolver) {
130
+ try {
131
+ const [dkimRecords, mxRecords, spfRecords] = await Promise.all([
132
+ resolver.resolveTxt(`resend._domainkey.${domain}`),
133
+ resolver.resolveMx(`send.${domain}`),
134
+ resolver.resolveTxt(`send.${domain}`),
135
+ ]);
136
+ return { dkimRecords, mxRecords, spfRecords, errorCode: null };
137
+ } catch (error) {
138
+ return {
139
+ dkimRecords: [],
140
+ mxRecords: [],
141
+ spfRecords: [],
142
+ errorCode: error && error.code ? error.code : 'dns_lookup_failed',
143
+ };
144
+ }
145
+ }
146
+
147
+ function recordsHaveResendDns({ dkimRecords, mxRecords, spfRecords }) {
148
+ const dkim = flattenTxt(dkimRecords);
149
+ const spf = flattenTxt(spfRecords);
150
+ return dkim.some((record) => DKIM_PUBLIC_KEY_RE.exec(record.trim()) !== null) &&
151
+ (mxRecords || []).some((record) => AMAZON_SES_MX_RE.exec(record.exchange || '') !== null) &&
152
+ spf.some((record) => AMAZON_SES_SPF_RE.exec(record) !== null);
153
+ }
154
+
155
+ async function hasResendSenderDns(domain, { dnsResolver } = {}) {
156
+ if (!domain || domain === 'resend.dev') return true;
157
+ if (isTrueEnv(process.env.THUMBGATE_ALLOW_UNVERIFIED_SENDER)) return true;
158
+ if (getVerifiedSenderDomains().has(domain)) return true;
159
+
160
+ const cacheKey = dnsResolver ? null : domain;
161
+ const cached = getCachedSenderDnsReadiness(cacheKey);
162
+ if (cached !== null) return cached;
163
+
164
+ const records = await readResendDnsRecords(domain, dnsResolver || dns);
165
+ return setCachedSenderDnsReadiness(cacheKey, !records.errorCode && recordsHaveResendDns(records));
166
+ }
167
+
168
+ async function resolveSenderAddress(requestedFrom, { dnsResolver } = {}) {
169
+ const from = requestedFrom || getFromAddress();
170
+ const domain = getEmailDomain(from);
171
+ const ready = await hasResendSenderDns(domain, { dnsResolver });
172
+ if (ready) return { from, senderFallback: null };
173
+
174
+ return {
175
+ from: DEFAULT_FROM,
176
+ senderFallback: {
177
+ requestedFrom: from,
178
+ fallbackFrom: DEFAULT_FROM,
179
+ domain,
180
+ reason: 'resend_dns_not_ready',
181
+ },
182
+ };
183
+ }
184
+
185
+ function validateSendEmailInput({ to, subject, html, text }) {
65
186
  if (!isNonEmptyString(to)) throw new Error('sendEmail: `to` is required');
66
187
  if (!isNonEmptyString(subject)) throw new Error('sendEmail: `subject` is required');
67
188
  if (!isNonEmptyString(html) && !isNonEmptyString(text)) {
68
189
  throw new Error('sendEmail: at least one of `html` or `text` is required');
69
190
  }
191
+ }
70
192
 
71
- const apiKey = getApiKey();
72
- if (!apiKey) {
73
- // eslint-disable-next-line no-console
74
- console.warn('[mailer] RESEND_API_KEY not set — skipping send to', to);
75
- return { sent: false, reason: 'no_api_key' };
76
- }
193
+ function warnSenderFallback(senderFallback) {
194
+ if (!senderFallback) return;
195
+ // eslint-disable-next-line no-console
196
+ console.warn(
197
+ `[mailer] Sender domain ${senderFallback.domain || '(unknown)'} is missing Resend DNS; ` +
198
+ `falling back to ${senderFallback.fallbackFrom}`,
199
+ );
200
+ }
77
201
 
202
+ function buildEmailPayload({ to, subject, html, text, replyTo, sender }) {
78
203
  const payload = {
79
- from: from || getFromAddress(),
204
+ from: sender.from,
80
205
  to: Array.isArray(to) ? to : [to],
81
206
  subject,
82
207
  reply_to: replyTo || getReplyTo(),
83
208
  };
84
209
  if (isNonEmptyString(html)) payload.html = html;
85
210
  if (isNonEmptyString(text)) payload.text = text;
211
+ return payload;
212
+ }
213
+
214
+ function parseJsonOrNull(bodyText) {
215
+ if (!bodyText) return null;
216
+ try {
217
+ return JSON.parse(bodyText);
218
+ } catch (error) {
219
+ if (!(error instanceof SyntaxError)) throw error;
220
+ return null;
221
+ }
222
+ }
223
+
224
+ async function postResendEmail({ fetcher, apiKey, payload }) {
225
+ const res = await fetcher(RESEND_ENDPOINT, {
226
+ method: 'POST',
227
+ headers: {
228
+ 'Content-Type': 'application/json',
229
+ Authorization: `Bearer ${apiKey}`,
230
+ },
231
+ body: JSON.stringify(payload),
232
+ });
233
+ const bodyText = typeof res.text === 'function' ? await res.text() : '';
234
+ return { res, bodyText, bodyJson: parseJsonOrNull(bodyText) };
235
+ }
236
+
237
+ function formatSendFailure(error) {
238
+ return error && error.message ? error.message : String(error);
239
+ }
240
+
241
+ function buildSendSuccess({ bodyJson, status, senderFallback }) {
242
+ return {
243
+ sent: true,
244
+ id: bodyJson?.id || null,
245
+ status,
246
+ ...(senderFallback ? { senderFallback } : {}),
247
+ };
248
+ }
249
+
250
+ /**
251
+ * Low-level send. Posts to the Resend API or no-ops when RESEND_API_KEY is
252
+ * missing. Never throws on network errors; returns a structured result instead.
253
+ */
254
+ async function sendEmail({ to, subject, html, text, from, replyTo, fetchImpl, dnsResolver } = {}) {
255
+ validateSendEmailInput({ to, subject, html, text });
256
+
257
+ const apiKey = getApiKey();
258
+ if (!apiKey) {
259
+ // eslint-disable-next-line no-console
260
+ console.warn('[mailer] RESEND_API_KEY not set — skipping send to', to);
261
+ return { sent: false, reason: 'no_api_key' };
262
+ }
263
+
264
+ const sender = await resolveSenderAddress(from || getFromAddress(), { dnsResolver });
265
+ warnSenderFallback(sender.senderFallback);
266
+ const payload = buildEmailPayload({ to, subject, html, text, replyTo, sender });
86
267
 
87
268
  const fetcher = fetchImpl || globalThis.fetch;
88
269
  if (typeof fetcher !== 'function') {
@@ -92,32 +273,17 @@ async function sendEmail({ to, subject, html, text, from, replyTo, fetchImpl } =
92
273
  }
93
274
 
94
275
  try {
95
- const res = await fetcher(RESEND_ENDPOINT, {
96
- method: 'POST',
97
- headers: {
98
- 'Content-Type': 'application/json',
99
- Authorization: `Bearer ${apiKey}`,
100
- },
101
- body: JSON.stringify(payload),
102
- });
103
-
104
- const bodyText = typeof res.text === 'function' ? await res.text() : '';
105
- let bodyJson = null;
106
- if (bodyText) {
107
- try { bodyJson = JSON.parse(bodyText); } catch (_) { /* leave as text */ }
108
- }
109
-
276
+ const { res, bodyText, bodyJson } = await postResendEmail({ fetcher, apiKey, payload });
110
277
  if (!res.ok) {
111
278
  // eslint-disable-next-line no-console
112
279
  console.warn(`[mailer] Resend returned ${res.status}:`, bodyText);
113
280
  return { sent: false, reason: 'api_error', status: res.status, body: bodyJson || bodyText };
114
281
  }
115
-
116
- return { sent: true, id: bodyJson && bodyJson.id ? bodyJson.id : null, status: res.status };
282
+ return buildSendSuccess({ bodyJson, status: res.status, senderFallback: sender.senderFallback });
117
283
  } catch (err) {
118
284
  // eslint-disable-next-line no-console
119
- console.warn('[mailer] send failed:', err && err.message ? err.message : err);
120
- return { sent: false, reason: 'exception', error: err && err.message ? err.message : String(err) };
285
+ console.warn('[mailer] send failed:', formatSendFailure(err));
286
+ return { sent: false, reason: 'exception', error: formatSendFailure(err) };
121
287
  }
122
288
  }
123
289
 
@@ -173,6 +339,7 @@ function renderTrialWelcomeBodies({ licenseKey, customerId, customerName, trialE
173
339
  const exampleFeedback =
174
340
  'thumbs down: the answer skipped exact files and tests; next time include paths, commands, and verification evidence.';
175
341
  const proofUrl = 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/VERIFICATION_EVIDENCE.md';
342
+ const supportEmail = getSupportEmail();
176
343
  const unsubscribeEmail = getUnsubscribeEmail();
177
344
  const businessName = getBusinessName();
178
345
  const businessAddress = getBusinessAddress();
@@ -205,7 +372,7 @@ function renderTrialWelcomeBodies({ licenseKey, customerId, customerName, trialE
205
372
  '',
206
373
  postscript,
207
374
  '',
208
- `Questions? Just reply to this email or write ${SUPPORT_EMAIL}.`,
375
+ `Questions? Just reply to this email or write ${supportEmail}.`,
209
376
  '',
210
377
  '— Igor, founder of ThumbGate',
211
378
  '',
@@ -222,6 +389,7 @@ function renderTrialWelcomeBodies({ licenseKey, customerId, customerName, trialE
222
389
  const safeDescription = escapeHtml(description);
223
390
  const safeExample = escapeHtml(exampleFeedback);
224
391
  const safePostscript = escapeHtml(postscript);
392
+ const safeSupportEmail = escapeHtml(supportEmail);
225
393
  const safeBusinessName = escapeHtml(businessName);
226
394
  const safeBusinessAddress = escapeHtml(businessAddress);
227
395
  const safeUnsubscribeEmail = escapeHtml(unsubscribeEmail);
@@ -281,7 +449,7 @@ function renderTrialWelcomeBodies({ licenseKey, customerId, customerName, trialE
281
449
  <p style="margin:0 0 4px;font-size:14px;line-height:1.6;color:#17212b;">— Igor, founder of ThumbGate</p>
282
450
  <p style="margin:0;font-size:13px;line-height:1.55;color:#526273;">
283
451
  Questions? Just reply to this email or write
284
- <a href="mailto:${SUPPORT_EMAIL}" style="color:#087a91;">${SUPPORT_EMAIL}</a>.
452
+ <a href="mailto:${safeSupportEmail}" style="color:#087a91;">${safeSupportEmail}</a>.
285
453
  </p>
286
454
  </td>
287
455
  </tr>
@@ -317,7 +485,7 @@ function renderTrialWelcomeBodies({ licenseKey, customerId, customerName, trialE
317
485
  * Never throws on send failures (beyond input validation); the Stripe webhook
318
486
  * must keep working even if email breaks.
319
487
  */
320
- async function sendTrialWelcomeEmail({ to, licenseKey, customerId, customerName, trialEndAt, fetchImpl } = {}) {
488
+ async function sendTrialWelcomeEmail({ to, licenseKey, customerId, customerName, trialEndAt, fetchImpl, dnsResolver } = {}) {
321
489
  if (!isNonEmptyString(to)) throw new Error('sendTrialWelcomeEmail: `to` is required');
322
490
  if (!isNonEmptyString(licenseKey)) throw new Error('sendTrialWelcomeEmail: `licenseKey` is required');
323
491
 
@@ -327,17 +495,19 @@ async function sendTrialWelcomeEmail({ to, licenseKey, customerId, customerName,
327
495
  ? `${name}, your ThumbGate Pro key is inside`
328
496
  : 'Your ThumbGate Pro key is inside';
329
497
 
330
- return sendEmail({ to, subject, html, text, replyTo: getReplyTo(), fetchImpl });
498
+ return sendEmail({ to, subject, html, text, replyTo: getReplyTo(), fetchImpl, dnsResolver });
331
499
  }
332
500
 
333
501
  module.exports = {
334
502
  sendEmail,
335
503
  sendTrialWelcomeEmail,
336
504
  renderTrialWelcomeBodies,
505
+ _resolveSenderAddress: resolveSenderAddress,
506
+ _hasResendSenderDns: hasResendSenderDns,
337
507
  _constants: {
338
508
  PRODUCT_NAME,
339
509
  DASHBOARD_URL,
340
- SUPPORT_EMAIL,
510
+ DEFAULT_CONTACT_EMAIL,
341
511
  DEFAULT_FROM,
342
512
  DEFAULT_REPLY_TO,
343
513
  DEFAULT_UNSUBSCRIBE_EMAIL,
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const { spawnSync } = require('node:child_process');
7
+
8
+ const { getRuntimeDir, resolveProjectDir } = require('./feedback-paths');
9
+
10
+ const FIXED_GH_BINARIES = [
11
+ '/usr/bin/gh',
12
+ '/usr/local/bin/gh',
13
+ '/opt/homebrew/bin/gh',
14
+ ];
15
+ const FIXED_GIT_BINARIES = [
16
+ '/usr/bin/git',
17
+ '/usr/local/bin/git',
18
+ '/opt/homebrew/bin/git',
19
+ ];
20
+
21
+ const CONTEXT_CACHE_MAX_AGE_MS = 120000;
22
+
23
+ function contextCachePath(options = {}) {
24
+ return path.join(getRuntimeDir(options), 'statusline-context.json');
25
+ }
26
+
27
+ function readContextCache(options = {}) {
28
+ try {
29
+ return JSON.parse(fs.readFileSync(contextCachePath(options), 'utf8'));
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ function writeContextCache(payload, options = {}) {
36
+ const targetPath = contextCachePath(options);
37
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
38
+ fs.writeFileSync(targetPath, JSON.stringify(payload, null, 2) + '\n');
39
+ return targetPath;
40
+ }
41
+
42
+ function isFreshCache(cache, now = Date.now()) {
43
+ if (!cache || !cache.updatedAt) {
44
+ return false;
45
+ }
46
+
47
+ const updatedAt = Date.parse(cache.updatedAt);
48
+ if (!Number.isFinite(updatedAt)) {
49
+ return false;
50
+ }
51
+
52
+ return (now - updatedAt) <= CONTEXT_CACHE_MAX_AGE_MS;
53
+ }
54
+
55
+ function resolveGhBinary(accessSync = fs.accessSync) {
56
+ for (const candidate of FIXED_GH_BINARIES) {
57
+ try {
58
+ accessSync(candidate, fs.constants.X_OK);
59
+ return candidate;
60
+ } catch {
61
+ continue;
62
+ }
63
+ }
64
+ return null;
65
+ }
66
+
67
+ function resolveGitBinary(options = {}) {
68
+ const env = options.env || process.env;
69
+ const configuredBinary = String(env.THUMBGATE_GIT_BIN || '').trim();
70
+ const candidates = configuredBinary
71
+ ? [configuredBinary, ...FIXED_GIT_BINARIES]
72
+ : FIXED_GIT_BINARIES;
73
+
74
+ for (const candidate of candidates) {
75
+ try {
76
+ fs.accessSync(candidate, fs.constants.X_OK);
77
+ return candidate;
78
+ } catch {
79
+ continue;
80
+ }
81
+ }
82
+
83
+ return null;
84
+ }
85
+
86
+ function runCommand(command, args, options = {}) {
87
+ const result = spawnSync(command, args, {
88
+ cwd: options.cwd,
89
+ encoding: 'utf8',
90
+ stdio: ['ignore', 'pipe', 'pipe'],
91
+ timeout: options.timeoutMs || 400,
92
+ });
93
+
94
+ if (result.status !== 0) {
95
+ return '';
96
+ }
97
+
98
+ return String(result.stdout || '').trim();
99
+ }
100
+
101
+ function getBranchName(projectDir) {
102
+ if (!projectDir) {
103
+ return '';
104
+ }
105
+
106
+ const gitBinary = resolveGitBinary();
107
+ if (!gitBinary) {
108
+ return '';
109
+ }
110
+
111
+ return runCommand(gitBinary, ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectDir, timeoutMs: 300 });
112
+ }
113
+
114
+ function inferWorkItemLabel(branchName = '') {
115
+ const normalized = String(branchName || '').trim();
116
+ if (!normalized) {
117
+ return '';
118
+ }
119
+
120
+ const explicitMatch = /(?:^|[/-])(AB#\d+)(?:$|[/-])/i.exec(normalized);
121
+ if (explicitMatch) {
122
+ return explicitMatch[1].toUpperCase();
123
+ }
124
+
125
+ const numericMatch = /(?:^|[/-])(\d{4,})(?:$|[/-])/.exec(normalized);
126
+ if (!numericMatch) {
127
+ return '';
128
+ }
129
+
130
+ return `AB#${numericMatch[1]}`;
131
+ }
132
+
133
+ function getPrNumber(projectDir, options = {}) {
134
+ if (!projectDir) {
135
+ return '';
136
+ }
137
+
138
+ const ghBinary = options.ghBinary || resolveGhBinary(options.accessSync);
139
+ if (!ghBinary) {
140
+ return '';
141
+ }
142
+
143
+ const prNumber = runCommand(
144
+ ghBinary,
145
+ ['pr', 'view', '--json', 'number', '--jq', '.number'],
146
+ { cwd: projectDir, timeoutMs: options.timeoutMs || 1000 }
147
+ );
148
+ return /^\d+$/.test(prNumber) ? prNumber : '';
149
+ }
150
+
151
+ function getStatuslineContext(options = {}) {
152
+ const env = options.env || process.env;
153
+ if (env._TEST_THUMBGATE_STATUSLINE_CONTEXT_JSON) {
154
+ return JSON.parse(env._TEST_THUMBGATE_STATUSLINE_CONTEXT_JSON);
155
+ }
156
+
157
+ const projectDir = resolveProjectDir({ env, cwd: options.cwd || process.cwd() });
158
+ const cache = readContextCache({ env, home: options.homeDir });
159
+ const branchName = (env.THUMBGATE_STATUSLINE_BRANCH || '').trim() || getBranchName(projectDir);
160
+ const workItemLabel = (env.THUMBGATE_STATUSLINE_WORK_ITEM || '').trim() || inferWorkItemLabel(branchName);
161
+
162
+ let prNumber = (env.THUMBGATE_STATUSLINE_PR_NUMBER || '').trim();
163
+ if (!prNumber && cache && cache.projectDir === projectDir && cache.branchName === branchName && isFreshCache(cache)) {
164
+ prNumber = String(cache.prNumber || '').trim();
165
+ }
166
+ if (!prNumber) {
167
+ prNumber = getPrNumber(projectDir, options);
168
+ }
169
+
170
+ const payload = {
171
+ branchName,
172
+ workItemLabel,
173
+ prNumber,
174
+ prLabel: prNumber ? `PR #${prNumber}` : '',
175
+ projectDir,
176
+ updatedAt: new Date().toISOString(),
177
+ };
178
+ writeContextCache(payload, { env, home: options.homeDir });
179
+ return payload;
180
+ }
181
+
182
+ function isCliInvocation(argv = process.argv) {
183
+ const invokedPath = argv[1];
184
+ return invokedPath ? path.resolve(invokedPath) === __filename : false;
185
+ }
186
+
187
+ if (isCliInvocation()) {
188
+ try {
189
+ process.stdout.write(JSON.stringify(getStatuslineContext()));
190
+ } catch {
191
+ process.exit(0);
192
+ }
193
+ }
194
+
195
+ module.exports = {
196
+ CONTEXT_CACHE_MAX_AGE_MS,
197
+ contextCachePath,
198
+ getBranchName,
199
+ getPrNumber,
200
+ getStatuslineContext,
201
+ inferWorkItemLabel,
202
+ isFreshCache,
203
+ readContextCache,
204
+ resolveGhBinary,
205
+ resolveGitBinary,
206
+ writeContextCache,
207
+ };
@@ -108,17 +108,28 @@ fi
108
108
  # ── ThumbGate package metadata ────────────────────────────────────────
109
109
  TG_VERSION="unknown"; TG_TIER="Free"
110
110
  _META_JSON=$(node "${SCRIPT_DIR}/statusline-meta.js" 2>/dev/null)
111
- if [ -n "$_META_JSON" ]; then
111
+ if [[ -n "$_META_JSON" ]]; then
112
112
  eval "$(echo "$_META_JSON" | jq -r '
113
113
  @sh "TG_VERSION=\(.version // "unknown")",
114
114
  @sh "TG_TIER=\(.tier // "Free")"
115
115
  ' 2>/dev/null)"
116
116
  fi
117
117
 
118
+ # ── Repo context (branch / work item / PR) ───────────────────────────
119
+ BRANCH_NAME=""; WORK_ITEM_LABEL=""; PR_LABEL=""
120
+ _CONTEXT_JSON=$(node "${SCRIPT_DIR}/statusline-context.js" 2>/dev/null)
121
+ if [[ -n "$_CONTEXT_JSON" ]]; then
122
+ eval "$(echo "$_CONTEXT_JSON" | jq -r '
123
+ @sh "BRANCH_NAME=\(.branchName // "")",
124
+ @sh "WORK_ITEM_LABEL=\(.workItemLabel // "")",
125
+ @sh "PR_LABEL=\(.prLabel // "")"
126
+ ' 2>/dev/null)"
127
+ fi
128
+
118
129
  # ── Control Tower stats ──────────────────────────────────────────
119
130
  SLO_V="0"; AT_RISK="0"; ANOMALIES="0"
120
131
  _TOWER_JSON=$(node "${SCRIPT_DIR}/statusline-tower.js" 2>/dev/null)
121
- if [ -n "$_TOWER_JSON" ]; then
132
+ if [[ -n "$_TOWER_JSON" ]]; then
122
133
  eval "$(echo "$_TOWER_JSON" | jq -r '
123
134
  @sh "SLO_V=\(.sloViolations // 0)",
124
135
  @sh "AT_RISK=\(.atRiskToolCount // 0)",
@@ -129,7 +140,7 @@ fi
129
140
  # ── Latest lesson (data available for extensions; not rendered in statusbar) ──
130
141
  LESSON_TEXT=""; LESSON_ID=""; LESSON_LABEL=""; LESSON_LINK=""
131
142
  _LESSON_JSON=$(node "${SCRIPT_DIR}/statusline-lesson.js" 2>/dev/null)
132
- if [ -n "$_LESSON_JSON" ]; then
143
+ if [[ -n "$_LESSON_JSON" ]]; then
133
144
  eval "$(echo "$_LESSON_JSON" | jq -r '
134
145
  @sh "LESSON_TEXT=\(.text // "")",
135
146
  @sh "LESSON_ID=\(.lessonId // "")",
@@ -155,7 +166,7 @@ osc_link() {
155
166
  # ThumbGate as a non-last row in a multi-line statusline should set this, because
156
167
  # some agents (Claude Code) silently drop downstream rows when a preceding row
157
168
  # contains OSC 8 sequences.
158
- if [ "${THUMBGATE_STATUSLINE_PLAIN:-0}" = "1" ]; then
169
+ if [[ "${THUMBGATE_STATUSLINE_PLAIN:-0}" = "1" ]]; then
159
170
  printf '%s' "$label"
160
171
  return 0
161
172
  fi
@@ -171,9 +182,9 @@ DOWN_LINK="$(osc_link "$DOWN_URL" "👎")"
171
182
  DASHBOARD_LINK="$(osc_link "$DASHBOARD_URL" "$DASHBOARD_LABEL")"
172
183
  LESSONS_LINK="$(osc_link "$LESSONS_URL" "$LESSONS_LABEL")"
173
184
  LATEST_LESSON_LINK=""
174
- if [ -n "$LESSON_LABEL" ]; then
185
+ if [[ -n "$LESSON_LABEL" ]]; then
175
186
  _DISPLAY_LINK="$LESSON_LINK"
176
- if [ -n "$LESSON_TEXT" ]; then
187
+ if [[ -n "$LESSON_TEXT" ]]; then
177
188
  LATEST_LESSON_LINK="$(osc_link "$_DISPLAY_LINK" "${LESSON_LABEL}: ${LESSON_TEXT}")"
178
189
  else
179
190
  LATEST_LESSON_LINK="$(osc_link "$_DISPLAY_LINK" "$LESSON_LABEL")"
@@ -181,20 +192,26 @@ if [ -n "$LESSON_LABEL" ]; then
181
192
  fi
182
193
 
183
194
  # ── Output (single line) ─────────────────────────────────────────
184
- LINE="ThumbGate v${TG_VERSION} · ${TG_TIER}"
185
- if [ "$UP" = "0" ] && [ "$DOWN" = "0" ]; then
186
- LINE="${D}${LINE} · no feedback yet${RST} · ${C}${DASHBOARD_LINK}${RST} · ${M}${LESSONS_LINK}${RST}"
187
- [ -n "$LATEST_LESSON_LINK" ] && LINE="${LINE} · ${D}${LATEST_LESSON_LINK}${RST}"
195
+ LINE=""
196
+ [[ -n "$BRANCH_NAME" ]] && LINE="${BRANCH_NAME}"
197
+ [[ -n "$WORK_ITEM_LABEL" ]] && LINE="${LINE:+${LINE} · }${WORK_ITEM_LABEL}"
198
+ LINE="${LINE:+${LINE} · }ThumbGate v${TG_VERSION} · ${TG_TIER}"
199
+ if [[ "$UP" = "0" && "$DOWN" = "0" ]]; then
200
+ LINE="${D}${LINE}${RST} · no feedback yet"
201
+ [[ -n "$PR_LABEL" ]] && LINE="${LINE} · ${D}${PR_LABEL}${RST}"
202
+ LINE="${LINE} · ${C}${DASHBOARD_LINK}${RST} · ${M}${LESSONS_LINK}${RST}"
203
+ [[ -n "$LATEST_LESSON_LINK" ]] && LINE="${LINE} · ${D}${LATEST_LESSON_LINK}${RST}"
188
204
  printf '%b\n' "$LINE"
189
205
  else
190
206
  LINE="${LINE} · ${G}${BD}${UP}${RST}${UP_LINK} ${R}${BD}${DOWN}${RST}${DOWN_LINK} ${ARROW}"
191
207
 
192
208
  # Control Tower alerts (if any)
193
- [ "${SLO_V:-0}" -gt 0 ] && LINE="${LINE} ${R}${SLO_V} SLO${RST}"
194
- [ "${AT_RISK:-0}" -gt 0 ] && LINE="${LINE} ${R}${AT_RISK}⚠${RST}"
195
- [ "${ANOMALIES:-0}" -gt 0 ] && LINE="${LINE} ${R}${ANOMALIES}☠${RST}"
209
+ [[ "${SLO_V:-0}" -gt 0 ]] && LINE="${LINE} ${R}${SLO_V} SLO${RST}"
210
+ [[ "${AT_RISK:-0}" -gt 0 ]] && LINE="${LINE} ${R}${AT_RISK}⚠${RST}"
211
+ [[ "${ANOMALIES:-0}" -gt 0 ]] && LINE="${LINE} ${R}${ANOMALIES}☠${RST}"
212
+ [[ -n "$PR_LABEL" ]] && LINE="${LINE} · ${D}${PR_LABEL}${RST}"
196
213
  LINE="${LINE} · ${C}${DASHBOARD_LINK}${RST} · ${M}${LESSONS_LINK}${RST}"
197
- [ -n "$LATEST_LESSON_LINK" ] && LINE="${LINE} · ${D}${LATEST_LESSON_LINK}${RST}"
214
+ [[ -n "$LATEST_LESSON_LINK" ]] && LINE="${LINE} · ${D}${LATEST_LESSON_LINK}${RST}"
198
215
 
199
216
  printf '%b\n' "$LINE"
200
217
  fi