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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/adapters/README.md +1 -1
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/mcp/server-stdio.js +90 -2
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +39 -5
- package/config/mcp-allowlists.json +10 -1
- package/package.json +13 -7
- package/public/index.html +2 -2
- package/scripts/autonomous-workflow.js +377 -0
- package/scripts/billing.js +4 -2
- package/scripts/feedback-loop.js +22 -0
- package/scripts/gates-engine.js +308 -5
- package/scripts/mailer/resend-mailer.js +210 -40
- package/scripts/statusline-context.js +207 -0
- package/scripts/statusline.sh +31 -14
- package/scripts/tool-registry.js +39 -0
- package/CHANGELOG.md +0 -702
|
@@ -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
|
|
22
|
+
const DEFAULT_CONTACT_EMAIL = 'igor.ganapolsky@gmail.com';
|
|
21
23
|
const DEFAULT_FROM = 'onboarding@resend.dev';
|
|
22
|
-
const DEFAULT_REPLY_TO =
|
|
23
|
-
const DEFAULT_UNSUBSCRIBE_EMAIL =
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
72
|
-
if (!
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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
|
|
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
|
|
120
|
-
return { sent: false, reason: 'exception', error:
|
|
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 ${
|
|
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:${
|
|
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
|
-
|
|
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
|
+
};
|
package/scripts/statusline.sh
CHANGED
|
@@ -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="
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|