thumbgate 1.4.0 → 1.4.1
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/README.md +25 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +195 -168
- package/adapters/chatgpt/INSTALL.md +59 -4
- package/bin/cli.js +4 -0
- package/config/github-about.json +1 -1
- package/package.json +9 -5
- package/public/index.html +44 -23
- package/scripts/auto-promote-gates.js +5 -3
- package/scripts/billing-setup.js +109 -0
- package/scripts/build-claude-mcpb.js +71 -5
- package/scripts/distribution-surfaces.js +28 -0
- package/scripts/feedback-to-rules.js +27 -8
- package/scripts/gates-engine.js +51 -7
- package/scripts/hosted-config.js +2 -0
- package/scripts/hybrid-feedback-context.js +26 -16
- package/scripts/operational-summary.js +41 -5
- package/scripts/ralph-loop.js +376 -0
- package/scripts/ralph-mode-ci.js +331 -0
- package/scripts/rotate-stripe-webhook-secret.js +314 -0
- package/src/api/server.js +23 -3
- package/scripts/__pycache__/train_from_feedback.cpython-312.pyc +0 -0
|
@@ -7,6 +7,8 @@ const ROOT = path.join(__dirname, '..');
|
|
|
7
7
|
const PRODUCTHUNT_URL = 'https://www.producthunt.com/products/thumbgate';
|
|
8
8
|
const CLAUDE_PLUGIN_LATEST_ASSET_NAME = 'thumbgate-claude-desktop.mcpb';
|
|
9
9
|
const CLAUDE_PLUGIN_NEXT_ASSET_NAME = 'thumbgate-claude-desktop-next.mcpb';
|
|
10
|
+
const CLAUDE_PLUGIN_REVIEW_LATEST_ASSET_NAME = 'thumbgate-claude-plugin-review.zip';
|
|
11
|
+
const CLAUDE_PLUGIN_REVIEW_NEXT_ASSET_NAME = 'thumbgate-claude-plugin-review-next.zip';
|
|
10
12
|
const CODEX_PLUGIN_LATEST_ASSET_NAME = 'thumbgate-codex-plugin.zip';
|
|
11
13
|
const CODEX_PLUGIN_NEXT_ASSET_NAME = 'thumbgate-codex-plugin-next.zip';
|
|
12
14
|
|
|
@@ -27,6 +29,11 @@ function getClaudePluginVersionedAssetName(version = getPackageVersion(ROOT)) {
|
|
|
27
29
|
return `thumbgate-claude-desktop-v${normalized}.mcpb`;
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
function getClaudePluginReviewVersionedAssetName(version = getPackageVersion(ROOT)) {
|
|
33
|
+
const normalized = String(version || '').replace(/^v/, '');
|
|
34
|
+
return `thumbgate-claude-plugin-review-v${normalized}.zip`;
|
|
35
|
+
}
|
|
36
|
+
|
|
30
37
|
function isPrereleaseVersion(version = getPackageVersion(ROOT)) {
|
|
31
38
|
return /^\d+\.\d+\.\d+-[0-9A-Za-z.-]+$/.test(String(version || '').trim());
|
|
32
39
|
}
|
|
@@ -35,6 +42,12 @@ function getClaudePluginChannelAssetName(version = getPackageVersion(ROOT)) {
|
|
|
35
42
|
return isPrereleaseVersion(version) ? CLAUDE_PLUGIN_NEXT_ASSET_NAME : CLAUDE_PLUGIN_LATEST_ASSET_NAME;
|
|
36
43
|
}
|
|
37
44
|
|
|
45
|
+
function getClaudePluginReviewChannelAssetName(version = getPackageVersion(ROOT)) {
|
|
46
|
+
return isPrereleaseVersion(version)
|
|
47
|
+
? CLAUDE_PLUGIN_REVIEW_NEXT_ASSET_NAME
|
|
48
|
+
: CLAUDE_PLUGIN_REVIEW_LATEST_ASSET_NAME;
|
|
49
|
+
}
|
|
50
|
+
|
|
38
51
|
function getClaudePluginLatestDownloadUrl(root = ROOT) {
|
|
39
52
|
return `${getRepositoryUrl(root)}/releases/latest/download/${CLAUDE_PLUGIN_LATEST_ASSET_NAME}`;
|
|
40
53
|
}
|
|
@@ -44,6 +57,15 @@ function getClaudePluginVersionedDownloadUrl(root = ROOT, version = getPackageVe
|
|
|
44
57
|
return `${getRepositoryUrl(root)}/releases/download/v${normalized}/${getClaudePluginVersionedAssetName(normalized)}`;
|
|
45
58
|
}
|
|
46
59
|
|
|
60
|
+
function getClaudePluginReviewLatestDownloadUrl(root = ROOT) {
|
|
61
|
+
return `${getRepositoryUrl(root)}/releases/latest/download/${CLAUDE_PLUGIN_REVIEW_LATEST_ASSET_NAME}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getClaudePluginReviewVersionedDownloadUrl(root = ROOT, version = getPackageVersion(root)) {
|
|
65
|
+
const normalized = String(version || '').replace(/^v/, '');
|
|
66
|
+
return `${getRepositoryUrl(root)}/releases/download/v${normalized}/${getClaudePluginReviewVersionedAssetName(normalized)}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
47
69
|
function getCodexPluginVersionedAssetName(version = getPackageVersion(ROOT)) {
|
|
48
70
|
const normalized = String(version || '').replace(/^v/, '');
|
|
49
71
|
return `thumbgate-codex-plugin-v${normalized}.zip`;
|
|
@@ -65,11 +87,17 @@ function getCodexPluginVersionedDownloadUrl(root = ROOT, version = getPackageVer
|
|
|
65
87
|
module.exports = {
|
|
66
88
|
CLAUDE_PLUGIN_LATEST_ASSET_NAME,
|
|
67
89
|
CLAUDE_PLUGIN_NEXT_ASSET_NAME,
|
|
90
|
+
CLAUDE_PLUGIN_REVIEW_LATEST_ASSET_NAME,
|
|
91
|
+
CLAUDE_PLUGIN_REVIEW_NEXT_ASSET_NAME,
|
|
68
92
|
CODEX_PLUGIN_LATEST_ASSET_NAME,
|
|
69
93
|
CODEX_PLUGIN_NEXT_ASSET_NAME,
|
|
70
94
|
PRODUCTHUNT_URL,
|
|
71
95
|
getClaudePluginChannelAssetName,
|
|
72
96
|
getClaudePluginLatestDownloadUrl,
|
|
97
|
+
getClaudePluginReviewChannelAssetName,
|
|
98
|
+
getClaudePluginReviewLatestDownloadUrl,
|
|
99
|
+
getClaudePluginReviewVersionedAssetName,
|
|
100
|
+
getClaudePluginReviewVersionedDownloadUrl,
|
|
73
101
|
getClaudePluginVersionedAssetName,
|
|
74
102
|
getClaudePluginVersionedDownloadUrl,
|
|
75
103
|
getCodexPluginChannelAssetName,
|
|
@@ -107,35 +107,54 @@ function analyze(entries) {
|
|
|
107
107
|
|
|
108
108
|
function promoteToGates(recurringIssues) {
|
|
109
109
|
const autoGatePath = getAutoGatesPath();
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
|
|
111
|
+
// Load existing auto-gates to MERGE, not overwrite
|
|
112
|
+
let autoGates = { version: 1, gates: [], promotionLog: [] };
|
|
113
|
+
if (fs.existsSync(autoGatePath)) {
|
|
114
|
+
try {
|
|
115
|
+
autoGates = JSON.parse(fs.readFileSync(autoGatePath, 'utf-8'));
|
|
116
|
+
if (!Array.isArray(autoGates.gates)) autoGates.gates = [];
|
|
117
|
+
if (!Array.isArray(autoGates.promotionLog)) autoGates.promotionLog = [];
|
|
118
|
+
} catch { /* start fresh if corrupt */ }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const existingIds = new Set(autoGates.gates.map(g => g.id));
|
|
122
|
+
let added = 0;
|
|
123
|
+
|
|
112
124
|
for (const issue of recurringIssues) {
|
|
113
125
|
if (issue.severity === 'critical') {
|
|
114
|
-
// Extract key nouns/verbs for pattern matching
|
|
115
126
|
const keywords = issue.pattern
|
|
116
127
|
.toLowerCase()
|
|
117
128
|
.replace(/[^a-z0-9\s]/g, '')
|
|
118
129
|
.split(/\s+/)
|
|
119
130
|
.filter(w => w.length > 4)
|
|
120
131
|
.slice(0, 3);
|
|
121
|
-
|
|
132
|
+
|
|
122
133
|
if (keywords.length >= 2) {
|
|
123
134
|
const pattern = keywords.join('.*');
|
|
135
|
+
const id = `auto-${issue.hasHighRisk ? 'hardened' : 'promoted'}-${Date.now().toString(36)}-${added}`;
|
|
136
|
+
|
|
137
|
+
// Skip if a gate with the same pattern already exists
|
|
138
|
+
const patternExists = autoGates.gates.some(g => g.pattern === pattern);
|
|
139
|
+
if (patternExists || existingIds.has(id)) continue;
|
|
140
|
+
|
|
124
141
|
autoGates.gates.push({
|
|
125
|
-
id
|
|
142
|
+
id,
|
|
126
143
|
pattern,
|
|
127
144
|
action: 'block',
|
|
128
145
|
message: `Automatically blocked due to repeated failures: ${issue.suggestedRule}`,
|
|
129
146
|
severity: 'critical',
|
|
130
|
-
source: 'feedback-
|
|
147
|
+
source: 'feedback-to-rules',
|
|
148
|
+
promotedAt: new Date().toISOString(),
|
|
131
149
|
});
|
|
150
|
+
added++;
|
|
132
151
|
}
|
|
133
152
|
}
|
|
134
153
|
}
|
|
135
154
|
|
|
136
|
-
if (
|
|
155
|
+
if (added > 0) {
|
|
137
156
|
fs.mkdirSync(path.dirname(autoGatePath), { recursive: true });
|
|
138
|
-
fs.writeFileSync(autoGatePath, JSON.stringify(autoGates, null, 2));
|
|
157
|
+
fs.writeFileSync(autoGatePath, JSON.stringify(autoGates, null, 2) + '\n');
|
|
139
158
|
}
|
|
140
159
|
}
|
|
141
160
|
|
package/scripts/gates-engine.js
CHANGED
|
@@ -595,10 +595,18 @@ function extractAffectedFiles(toolName, toolInput = {}) {
|
|
|
595
595
|
}
|
|
596
596
|
|
|
597
597
|
function isHighRiskAction(toolName, toolInput = {}, affectedFiles = []) {
|
|
598
|
-
if (EDIT_LIKE_TOOLS.has(toolName)
|
|
598
|
+
if (EDIT_LIKE_TOOLS.has(toolName)) return true;
|
|
599
599
|
if (toolName !== 'Bash') return false;
|
|
600
600
|
const command = String(toolInput.command || '');
|
|
601
|
-
|
|
601
|
+
// Original high-risk pattern (git writes, publishes, destructive ops)
|
|
602
|
+
if (HIGH_RISK_BASH_PATTERN.test(command)) return true;
|
|
603
|
+
// Broadened: any Bash command that modifies files or has side effects.
|
|
604
|
+
// Excludes pure read/analysis commands (node --test, cat, ls, echo, etc.)
|
|
605
|
+
// to avoid false positives on benign operations.
|
|
606
|
+
if (/\b(sed|awk|mv|cp|chmod|chown|truncate|tee|patch)\b/.test(command)) return true;
|
|
607
|
+
if (/\b(npm\s+(?:run|exec|install)|yarn|pnpm)\b/.test(command)) return true;
|
|
608
|
+
if (/\b(curl|wget)\b/.test(command)) return true;
|
|
609
|
+
return false;
|
|
602
610
|
}
|
|
603
611
|
|
|
604
612
|
function isScopeEnforcedAction(toolName, toolInput = {}, affectedFiles = []) {
|
|
@@ -1448,9 +1456,16 @@ function evaluateSecretGuard(input = {}) {
|
|
|
1448
1456
|
// PreToolUse hook interface (stdin/stdout JSON)
|
|
1449
1457
|
// ---------------------------------------------------------------------------
|
|
1450
1458
|
|
|
1451
|
-
function formatOutput(result) {
|
|
1459
|
+
function formatOutput(result, behavioralContext) {
|
|
1452
1460
|
if (!result) {
|
|
1453
|
-
// No gate matched —
|
|
1461
|
+
// No gate matched — inject behavioral context if available
|
|
1462
|
+
if (behavioralContext) {
|
|
1463
|
+
return JSON.stringify({
|
|
1464
|
+
hookSpecificOutput: {
|
|
1465
|
+
additionalContext: behavioralContext,
|
|
1466
|
+
},
|
|
1467
|
+
});
|
|
1468
|
+
}
|
|
1454
1469
|
return JSON.stringify({});
|
|
1455
1470
|
}
|
|
1456
1471
|
|
|
@@ -1468,9 +1483,10 @@ function formatOutput(result) {
|
|
|
1468
1483
|
}
|
|
1469
1484
|
|
|
1470
1485
|
if (result.decision === 'warn') {
|
|
1486
|
+
const extra = behavioralContext ? `\n${behavioralContext}` : '';
|
|
1471
1487
|
return JSON.stringify({
|
|
1472
1488
|
hookSpecificOutput: {
|
|
1473
|
-
additionalContext: `[GATE:${result.gate}] WARNING: ${result.message}${reasoningSuffix}`,
|
|
1489
|
+
additionalContext: `[GATE:${result.gate}] WARNING: ${result.message}${reasoningSuffix}${extra}`,
|
|
1474
1490
|
},
|
|
1475
1491
|
});
|
|
1476
1492
|
}
|
|
@@ -1478,6 +1494,30 @@ function formatOutput(result) {
|
|
|
1478
1494
|
return JSON.stringify({});
|
|
1479
1495
|
}
|
|
1480
1496
|
|
|
1497
|
+
/**
|
|
1498
|
+
* Build behavioral context string from recurring feedback patterns.
|
|
1499
|
+
* Injected as additionalContext on EVERY tool call so the AI constantly
|
|
1500
|
+
* sees its failure patterns — even when no gate blocks.
|
|
1501
|
+
*/
|
|
1502
|
+
function buildBehavioralContext() {
|
|
1503
|
+
const hybrid = getHybridFeedbackModule();
|
|
1504
|
+
if (!hybrid || typeof hybrid.buildHybridState !== 'function') return null;
|
|
1505
|
+
|
|
1506
|
+
try {
|
|
1507
|
+
const state = hybrid.buildHybridState({});
|
|
1508
|
+
if (!state || !state.recurringNegativePatterns || state.recurringNegativePatterns.length === 0) {
|
|
1509
|
+
return null;
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
const constraints = hybrid.deriveConstraints(state, 3);
|
|
1513
|
+
if (constraints.length === 0) return null;
|
|
1514
|
+
|
|
1515
|
+
return `[ThumbGate] Recurring failure patterns (enforce these):\n${constraints.map(c => ` - ${c}`).join('\n')}`;
|
|
1516
|
+
} catch {
|
|
1517
|
+
return null;
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1481
1521
|
async function runAsync(input) {
|
|
1482
1522
|
const secretGuard = evaluateSecretGuard(input);
|
|
1483
1523
|
if (secretGuard) {
|
|
@@ -1504,7 +1544,8 @@ async function runAsync(input) {
|
|
|
1504
1544
|
}
|
|
1505
1545
|
}
|
|
1506
1546
|
|
|
1507
|
-
|
|
1547
|
+
const behavioralContext = buildBehavioralContext();
|
|
1548
|
+
return formatOutput(result, behavioralContext);
|
|
1508
1549
|
}
|
|
1509
1550
|
|
|
1510
1551
|
function run(input) {
|
|
@@ -1533,7 +1574,8 @@ function run(input) {
|
|
|
1533
1574
|
}
|
|
1534
1575
|
}
|
|
1535
1576
|
|
|
1536
|
-
|
|
1577
|
+
const behavioralContext = buildBehavioralContext();
|
|
1578
|
+
return formatOutput(result, behavioralContext);
|
|
1537
1579
|
}
|
|
1538
1580
|
|
|
1539
1581
|
// ---------------------------------------------------------------------------
|
|
@@ -1753,6 +1795,8 @@ module.exports = {
|
|
|
1753
1795
|
SESSION_ACTION_TTL_MS,
|
|
1754
1796
|
PROTECTED_APPROVAL_TTL_MS,
|
|
1755
1797
|
DEFAULT_PROTECTED_FILE_GLOBS,
|
|
1798
|
+
buildBehavioralContext,
|
|
1799
|
+
isHighRiskAction,
|
|
1756
1800
|
};
|
|
1757
1801
|
|
|
1758
1802
|
// ---------------------------------------------------------------------------
|
package/scripts/hosted-config.js
CHANGED
|
@@ -123,6 +123,7 @@ function resolveHostedBillingConfig({ requestOrigin } = {}, env = process.env) {
|
|
|
123
123
|
const proPriceDollars = normalizePriceDollars(env.THUMBGATE_PRO_PRICE_DOLLARS) || DEFAULT_PRO_PRICE_DOLLARS;
|
|
124
124
|
const proPriceLabel = env.THUMBGATE_PRO_PRICE_LABEL || DEFAULT_PRO_PRICE_LABEL;
|
|
125
125
|
const gaMeasurementId = normalizeTrackingId(env.THUMBGATE_GA_MEASUREMENT_ID, GA_MEASUREMENT_ID_PATTERN);
|
|
126
|
+
const posthogApiKey = env.POSTHOG_API_KEY || '';
|
|
126
127
|
const googleSiteVerification = normalizeTrackingId(env.THUMBGATE_GOOGLE_SITE_VERIFICATION);
|
|
127
128
|
|
|
128
129
|
return {
|
|
@@ -137,6 +138,7 @@ function resolveHostedBillingConfig({ requestOrigin } = {}, env = process.env) {
|
|
|
137
138
|
proPriceLabel,
|
|
138
139
|
gaMeasurementId,
|
|
139
140
|
googleSiteVerification,
|
|
141
|
+
posthogApiKey,
|
|
140
142
|
};
|
|
141
143
|
}
|
|
142
144
|
|
|
@@ -508,25 +508,21 @@ function evaluateCompiledGuards(artifact, toolName, toolInput) {
|
|
|
508
508
|
const normTool = (toolName || '').toLowerCase();
|
|
509
509
|
|
|
510
510
|
for (const guard of artifact.guards) {
|
|
511
|
-
// Check if tool context is relevant
|
|
512
511
|
const guardText = normalize(guard.text || '');
|
|
513
512
|
const toolMentioned = guardText.includes(normTool) || normTool === 'unknown';
|
|
514
513
|
|
|
515
|
-
|
|
516
|
-
return {
|
|
517
|
-
mode: guard.mode || 'warn',
|
|
518
|
-
reason: `Matched guard pattern (count: ${guard.count}): "${(guard.text || '').slice(0, 80)}"`,
|
|
519
|
-
source: 'compiled',
|
|
520
|
-
guardHash: guard.hash,
|
|
521
|
-
attributed: guard.attributed,
|
|
522
|
-
};
|
|
523
|
-
}
|
|
514
|
+
const keywordMatch = hasTwoKeywordHits(normInput, guard.words || []);
|
|
524
515
|
|
|
525
|
-
//
|
|
526
|
-
|
|
516
|
+
// Match if: keyword hits in input, OR tool mentioned + high count.
|
|
517
|
+
// Previously tool-name matching only worked for short inputs — this was
|
|
518
|
+
// a false-negative gap that let tool-specific patterns slip through.
|
|
519
|
+
if (keywordMatch || (toolMentioned && guard.count >= (artifact.blockThreshold || 3))) {
|
|
520
|
+
const reason = keywordMatch
|
|
521
|
+
? `Matched guard pattern (count: ${guard.count}): "${(guard.text || '').slice(0, 80)}"`
|
|
522
|
+
: `Tool "${toolName}" has recurring negative patterns (count: ${guard.count})`;
|
|
527
523
|
return {
|
|
528
524
|
mode: guard.mode || 'warn',
|
|
529
|
-
reason
|
|
525
|
+
reason,
|
|
530
526
|
source: 'compiled',
|
|
531
527
|
guardHash: guard.hash,
|
|
532
528
|
attributed: guard.attributed,
|
|
@@ -596,6 +592,12 @@ function evaluatePretoolFromState(state, toolName, toolInput) {
|
|
|
596
592
|
* @param {string} [opts.attributedFeedbackPath]
|
|
597
593
|
* @returns {{ mode: 'block'|'warn'|'allow', reason: string, source: string }}
|
|
598
594
|
*/
|
|
595
|
+
/**
|
|
596
|
+
* Max age (ms) before compiled guards are considered stale and live state
|
|
597
|
+
* is also consulted. Default: 1 hour.
|
|
598
|
+
*/
|
|
599
|
+
const GUARD_STALENESS_MS = 60 * 60 * 1000;
|
|
600
|
+
|
|
599
601
|
function evaluatePretool(toolName, toolInput, opts) {
|
|
600
602
|
const o = opts || {};
|
|
601
603
|
|
|
@@ -605,11 +607,18 @@ function evaluatePretool(toolName, toolInput, opts) {
|
|
|
605
607
|
if (artifact) {
|
|
606
608
|
const result = evaluateCompiledGuards(artifact, toolName, toolInput);
|
|
607
609
|
if (result.mode !== 'allow') return result;
|
|
608
|
-
|
|
609
|
-
|
|
610
|
+
|
|
611
|
+
// Check staleness: if compiled artifact is fresh enough, trust it
|
|
612
|
+
const compiledAt = artifact.compiledAt ? Date.parse(artifact.compiledAt) : 0;
|
|
613
|
+
const age = Date.now() - compiledAt;
|
|
614
|
+
if (age < GUARD_STALENESS_MS) {
|
|
615
|
+
return result; // Fresh compiled artifact says allow — trust it
|
|
616
|
+
}
|
|
617
|
+
// Stale artifact said allow — fall through to live evaluation
|
|
618
|
+
// in case new feedback was captured since compilation
|
|
610
619
|
}
|
|
611
620
|
|
|
612
|
-
// Slow path: build live state
|
|
621
|
+
// Slow path: build live state (also used when compiled guards are stale)
|
|
613
622
|
const state = buildHybridState({
|
|
614
623
|
feedbackLogPath: o.feedbackLogPath,
|
|
615
624
|
attributedFeedbackPath: o.attributedFeedbackPath,
|
|
@@ -683,6 +692,7 @@ module.exports = {
|
|
|
683
692
|
readJsonl,
|
|
684
693
|
getHybridPaths,
|
|
685
694
|
PATHS,
|
|
695
|
+
GUARD_STALENESS_MS,
|
|
686
696
|
};
|
|
687
697
|
|
|
688
698
|
if (require.main === module) {
|
|
@@ -1,23 +1,60 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const os = require('node:os');
|
|
3
6
|
const { getBillingSummaryLive } = require('./billing');
|
|
4
7
|
const { resolveAnalyticsWindow } = require('./analytics-window');
|
|
5
8
|
const { resolveHostedBillingConfig } = require('./hosted-config');
|
|
6
9
|
|
|
10
|
+
// Configure fetch proxy when running behind a corporate/sandbox proxy
|
|
11
|
+
(function configureProxy() {
|
|
12
|
+
const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy
|
|
13
|
+
|| process.env.HTTP_PROXY || process.env.http_proxy;
|
|
14
|
+
if (!proxyUrl) return;
|
|
15
|
+
try {
|
|
16
|
+
const { ProxyAgent, setGlobalDispatcher } = require('undici');
|
|
17
|
+
setGlobalDispatcher(new ProxyAgent(proxyUrl));
|
|
18
|
+
} catch {
|
|
19
|
+
// undici not available — fetch will use default dispatcher
|
|
20
|
+
}
|
|
21
|
+
}());
|
|
22
|
+
|
|
23
|
+
const OPERATOR_CONFIG_PATH = path.join(os.homedir(), '.config', 'thumbgate', 'operator.json');
|
|
24
|
+
|
|
7
25
|
function normalizeText(value) {
|
|
8
26
|
if (value === undefined || value === null) return null;
|
|
9
27
|
const text = String(value).trim();
|
|
10
28
|
return text || null;
|
|
11
29
|
}
|
|
12
30
|
|
|
31
|
+
function loadOperatorConfig(configPath = OPERATOR_CONFIG_PATH) {
|
|
32
|
+
try {
|
|
33
|
+
const raw = fs.readFileSync(configPath, 'utf8');
|
|
34
|
+
const parsed = JSON.parse(raw);
|
|
35
|
+
return {
|
|
36
|
+
operatorKey: normalizeText(parsed.operatorKey),
|
|
37
|
+
baseUrl: normalizeText(parsed.baseUrl),
|
|
38
|
+
};
|
|
39
|
+
} catch {
|
|
40
|
+
return { operatorKey: null, baseUrl: null };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
13
44
|
function shouldPreferHostedSummary() {
|
|
14
45
|
return String(process.env.THUMBGATE_METRICS_SOURCE || '').trim().toLowerCase() !== 'local';
|
|
15
46
|
}
|
|
16
47
|
|
|
17
48
|
function resolveHostedSummaryConfig() {
|
|
18
49
|
const runtimeConfig = resolveHostedBillingConfig();
|
|
19
|
-
const
|
|
20
|
-
|
|
50
|
+
const operatorConfig = loadOperatorConfig();
|
|
51
|
+
// Priority: env THUMBGATE_OPERATOR_KEY > local config file > env THUMBGATE_API_KEY
|
|
52
|
+
const apiKey = normalizeText(process.env.THUMBGATE_OPERATOR_KEY)
|
|
53
|
+
|| operatorConfig.operatorKey
|
|
54
|
+
|| normalizeText(process.env.THUMBGATE_API_KEY);
|
|
55
|
+
const apiBaseUrl = normalizeText(process.env.THUMBGATE_BILLING_API_BASE_URL)
|
|
56
|
+
|| operatorConfig.baseUrl
|
|
57
|
+
|| runtimeConfig.billingApiBaseUrl;
|
|
21
58
|
return {
|
|
22
59
|
apiBaseUrl,
|
|
23
60
|
apiKey,
|
|
@@ -74,9 +111,7 @@ async function getOperationalBillingSummary(options = {}) {
|
|
|
74
111
|
};
|
|
75
112
|
} catch (err) {
|
|
76
113
|
const reason = err && err.message ? err.message : 'hosted_summary_unavailable';
|
|
77
|
-
|
|
78
|
-
// to avoid falling back to local state. See docs/PRICING_RESEARCH_2026-03-10.md
|
|
79
|
-
console.warn(`[operational-summary] Hosted billing not configured — falling back to local state. Reason: ${reason}`);
|
|
114
|
+
console.warn(`[operational-summary] Hosted billing unavailable — falling back to local state. Reason: ${reason}`);
|
|
80
115
|
return {
|
|
81
116
|
source: 'local',
|
|
82
117
|
summary: await getBillingSummaryLive(analyticsWindow),
|
|
@@ -90,4 +125,5 @@ module.exports = {
|
|
|
90
125
|
getOperationalBillingSummary,
|
|
91
126
|
resolveHostedSummaryConfig,
|
|
92
127
|
shouldPreferHostedSummary,
|
|
128
|
+
loadOperatorConfig,
|
|
93
129
|
};
|