thumbgate 1.7.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.
@@ -511,6 +511,7 @@ function buildTrialActivationEmail({ customerEmail, apiKey, sessionId, planId, a
511
511
  const origin = resolvePublicAppOrigin(appOrigin);
512
512
  const dashboardUrl = joinPublicUrl(origin, '/dashboard');
513
513
  const docsUrl = 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/VERIFICATION_EVIDENCE.md';
514
+ const supportEmail = process.env.THUMBGATE_SUPPORT_EMAIL || CONFIG.TRIAL_EMAIL_REPLY_TO || 'igor.ganapolsky@gmail.com';
514
515
  const command = `npx thumbgate pro --activate --key=${apiKey || ''}`;
515
516
  const subject = 'Your 7-day ThumbGate Pro trial is live';
516
517
  const preheader = 'Activate Pro in one command, open the dashboard, and start blocking repeated AI coding mistakes.';
@@ -519,6 +520,7 @@ function buildTrialActivationEmail({ customerEmail, apiKey, sessionId, planId, a
519
520
  const exampleFeedback = 'thumbs down: the answer skipped exact files and tests; next time include paths, commands, and verification evidence.';
520
521
  const safeDashboardUrl = escapeHtml(dashboardUrl);
521
522
  const safeDocsUrl = escapeHtml(docsUrl);
523
+ const safeSupportEmail = escapeHtml(supportEmail);
522
524
  const safeCommand = escapeHtml(command);
523
525
  const safeApiKey = escapeHtml(apiKey || '');
524
526
  return {
@@ -544,7 +546,7 @@ function buildTrialActivationEmail({ customerEmail, apiKey, sessionId, planId, a
544
546
  apiKey,
545
547
  '',
546
548
  `Verification evidence: ${docsUrl}`,
547
- 'Keep this key private. Questions? Reply to this email or write hello@thumbgate.app.',
549
+ `Keep this key private. Questions? Reply to this email or write ${supportEmail}.`,
548
550
  sessionId ? `Stripe session: ${sessionId}` : null,
549
551
  planId ? `Plan: ${planId}` : null,
550
552
  ].filter(Boolean).join('\n'),
@@ -591,7 +593,7 @@ function buildTrialActivationEmail({ customerEmail, apiKey, sessionId, planId, a
591
593
 
592
594
  <p style="margin:0;font-size:13px;line-height:1.6;color:#526273;">
593
595
  Proof trail: <a href="${safeDocsUrl}" style="color:#087a91;">verification evidence</a>.
594
- Keep this key private. Questions? Reply here or write <a href="mailto:hello@thumbgate.app" style="color:#087a91;">hello@thumbgate.app</a>.
596
+ Keep this key private. Questions? Reply here or write <a href="mailto:${safeSupportEmail}" style="color:#087a91;">${safeSupportEmail}</a>.
595
597
  </p>
596
598
  ${sessionId ? `<p style="margin:12px 0 0;font-size:12px;line-height:1.5;color:#7a8790;">Stripe session: ${escapeHtml(sessionId)}</p>` : ''}
597
599
  </td>
@@ -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
+ };