thumbgate 1.27.18 → 1.27.20

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.
Files changed (170) hide show
  1. package/.claude/commands/dashboard.md +15 -0
  2. package/.claude/commands/thumbgate-blocked.md +27 -0
  3. package/.claude/commands/thumbgate-dashboard.md +15 -0
  4. package/.claude/commands/thumbgate-doctor.md +30 -0
  5. package/.claude/commands/thumbgate-guard.md +36 -0
  6. package/.claude/commands/thumbgate-protect.md +30 -0
  7. package/.claude/commands/thumbgate-rules.md +30 -0
  8. package/.claude-plugin/plugin.json +4 -3
  9. package/.well-known/agentic-verify.txt +1 -0
  10. package/.well-known/llms.txt +33 -12
  11. package/.well-known/mcp/server-card.json +8 -8
  12. package/README.md +246 -30
  13. package/adapters/claude/.mcp.json +2 -2
  14. package/adapters/codex/config.toml +2 -2
  15. package/adapters/gcp/dfcx-webhook-gate.js +295 -0
  16. package/adapters/gemini/function-declarations.json +1 -0
  17. package/adapters/letta/README.md +41 -0
  18. package/adapters/letta/thumbgate-letta-adapter.js +133 -0
  19. package/adapters/mcp/server-stdio.js +263 -11
  20. package/adapters/opencode/opencode.json +1 -1
  21. package/adapters/policy-engine/ethicore-guardian-client.js +68 -0
  22. package/adapters/policy-engine/thumbgate-policy-engine-adapter.js +260 -0
  23. package/bench/observability-eval-suite.json +26 -0
  24. package/bench/thumbgate-bench.json +2 -2
  25. package/bin/cli.js +1456 -122
  26. package/bin/dashboard-cli.js +7 -0
  27. package/bin/postinstall.js +11 -27
  28. package/commands/dashboard.md +15 -0
  29. package/commands/thumbgate-dashboard.md +15 -0
  30. package/config/gate-classifier-routing.json +98 -0
  31. package/config/gate-templates.json +216 -0
  32. package/config/gates/claim-verification.json +12 -0
  33. package/config/gates/default.json +31 -2
  34. package/config/github-about.json +2 -2
  35. package/config/mcp-allowlists.json +23 -13
  36. package/config/merge-quality-checks.json +0 -1
  37. package/config/model-candidates.json +121 -6
  38. package/config/post-deploy-marketing-pages.json +80 -0
  39. package/config/tessl-tiles.json +1 -3
  40. package/openapi/openapi.yaml +12 -0
  41. package/package.json +225 -100
  42. package/public/about.html +162 -0
  43. package/public/agent-manager.html +179 -0
  44. package/public/agents-cost-savings.html +153 -0
  45. package/public/ai-malpractice-prevention.html +818 -0
  46. package/public/assets/brand/github-social-preview.png +0 -0
  47. package/public/assets/brand/thumbgate-icon-512.png +0 -0
  48. package/public/assets/brand/thumbgate-icon-pro-512.png +0 -0
  49. package/public/assets/brand/thumbgate-icon-team-512.png +0 -0
  50. package/public/assets/brand/thumbgate-logo-1200x360.png +0 -0
  51. package/public/assets/brand/thumbgate-logo-transparent.svg +28 -0
  52. package/public/assets/brand/thumbgate-mark-inline-v3.svg +18 -0
  53. package/public/assets/brand/thumbgate-mark-pro.svg +23 -0
  54. package/public/assets/brand/thumbgate-mark-team.svg +26 -0
  55. package/public/assets/brand/thumbgate-mark.svg +21 -0
  56. package/public/assets/brand/thumbgate-wordmark.svg +20 -0
  57. package/public/assets/claude-thumbgate-statusbar.svg +8 -0
  58. package/public/assets/codex-thumbgate-statusbar-test.svg +9 -0
  59. package/public/assets/legal-intake-control-flow.svg +66 -0
  60. package/public/blog.html +4 -4
  61. package/public/brand/thumbgate-mark.svg +19 -0
  62. package/public/brand/thumbgate-og.svg +16 -0
  63. package/public/chatgpt-app.html +330 -0
  64. package/public/codex-enterprise.html +123 -0
  65. package/public/codex-plugin.html +72 -20
  66. package/public/compare.html +31 -8
  67. package/public/dashboard.html +930 -166
  68. package/public/diagnostic.html +345 -0
  69. package/public/federal.html +2 -2
  70. package/public/guide.html +33 -13
  71. package/public/index.html +469 -111
  72. package/public/install.html +193 -0
  73. package/public/js/buyer-intent.js +672 -0
  74. package/public/learn.html +183 -18
  75. package/public/lessons.html +168 -10
  76. package/public/numbers.html +7 -7
  77. package/public/pricing.html +399 -0
  78. package/public/pro.html +34 -11
  79. package/scripts/action-receipts.js +324 -0
  80. package/scripts/activation-quickstart.js +187 -0
  81. package/scripts/agent-memory-lifecycle.js +211 -0
  82. package/scripts/agent-operations-planner.js +621 -0
  83. package/scripts/agent-readiness.js +20 -3
  84. package/scripts/agent-reward-model.js +53 -1
  85. package/scripts/ai-component-inventory.js +367 -0
  86. package/scripts/async-eval-observability.js +236 -0
  87. package/scripts/audit.js +65 -0
  88. package/scripts/auto-promote-gates.js +82 -10
  89. package/scripts/auto-wire-hooks.js +14 -0
  90. package/scripts/aws-blocks-guardrails.js +272 -0
  91. package/scripts/billing.js +93 -1
  92. package/scripts/bot-detection.js +61 -3
  93. package/scripts/build-metadata.js +50 -10
  94. package/scripts/classifier-routing.js +130 -0
  95. package/scripts/cli-feedback.js +4 -2
  96. package/scripts/cli-schema.js +97 -0
  97. package/scripts/cli-telemetry.js +6 -1
  98. package/scripts/commercial-offer.js +82 -2
  99. package/scripts/context-manager.js +74 -6
  100. package/scripts/dashboard-chat.js +332 -0
  101. package/scripts/dashboard.js +68 -2
  102. package/scripts/export-databricks-bundle.js +5 -1
  103. package/scripts/export-dpo-pairs.js +7 -2
  104. package/scripts/feedback-aggregate.js +281 -0
  105. package/scripts/feedback-loop.js +123 -1
  106. package/scripts/feedback-quality.js +87 -0
  107. package/scripts/feedback-sanitizer.js +105 -0
  108. package/scripts/filesystem-search.js +35 -10
  109. package/scripts/gate-stats.js +89 -0
  110. package/scripts/gates-engine.js +1176 -85
  111. package/scripts/gemini-embedding-policy.js +2 -1
  112. package/scripts/hook-runtime.js +20 -14
  113. package/scripts/hook-stop-anti-claim.js +301 -0
  114. package/scripts/hook-thumbgate-cache-updater.js +18 -2
  115. package/scripts/hybrid-feedback-context.js +142 -7
  116. package/scripts/install-shim.js +87 -0
  117. package/scripts/lesson-inference.js +8 -3
  118. package/scripts/lesson-search.js +17 -1
  119. package/scripts/license.js +10 -10
  120. package/scripts/llm-client.js +169 -4
  121. package/scripts/local-model-profile.js +15 -8
  122. package/scripts/mcp-config.js +7 -1
  123. package/scripts/mcp-oauth.js +293 -0
  124. package/scripts/memory-scope-readiness.js +159 -0
  125. package/scripts/meta-agent-loop.js +36 -0
  126. package/scripts/noop-detect.js +285 -0
  127. package/scripts/operational-integrity.js +39 -5
  128. package/scripts/oss-pr-opportunity-scout.js +35 -5
  129. package/scripts/parallel-workflow-orchestrator.js +293 -0
  130. package/scripts/plan-gate.js +243 -0
  131. package/scripts/plausible-domain-config.js +99 -0
  132. package/scripts/plausible-server-events.js +9 -6
  133. package/scripts/pro-local-dashboard.js +4 -4
  134. package/scripts/proxy-pointer-rag-guardrails.js +42 -1
  135. package/scripts/published-cli.js +0 -8
  136. package/scripts/qa-scenario-planner.js +136 -0
  137. package/scripts/rate-limiter.js +64 -13
  138. package/scripts/repeat-metric.js +137 -0
  139. package/scripts/secret-fixture-tokens.js +61 -0
  140. package/scripts/secret-redaction.js +166 -0
  141. package/scripts/secret-scanner.js +44 -5
  142. package/scripts/security-scanner.js +260 -10
  143. package/scripts/self-distill-agent.js +3 -1
  144. package/scripts/self-harness-optimizer.js +141 -0
  145. package/scripts/self-healing-check.js +193 -0
  146. package/scripts/self-protection.js +90 -0
  147. package/scripts/seo-gsd.js +916 -7
  148. package/scripts/silent-failure-cluster.js +531 -0
  149. package/scripts/statusline-cache-path.js +17 -2
  150. package/scripts/statusline-cache-read.js +57 -0
  151. package/scripts/statusline-local-stats.js +9 -1
  152. package/scripts/statusline-meta.js +28 -2
  153. package/scripts/statusline.sh +20 -4
  154. package/scripts/sync-telemetry-from-prod.js +374 -0
  155. package/scripts/telemetry-analytics.js +357 -0
  156. package/scripts/thompson-sampling.js +31 -10
  157. package/scripts/thumbgate-bench.js +16 -1
  158. package/scripts/thumbgate-search.js +85 -19
  159. package/scripts/tool-contract-validator.js +76 -0
  160. package/scripts/tool-registry.js +169 -1
  161. package/scripts/trajectory-scorer.js +63 -0
  162. package/scripts/vector-store.js +45 -0
  163. package/scripts/verify-marketing-pages-deployed.js +212 -0
  164. package/scripts/visitor-journey.js +172 -0
  165. package/scripts/workflow-sentinel.js +286 -53
  166. package/scripts/workspace-evolver.js +62 -2
  167. package/src/api/server.js +2683 -319
  168. package/.claude-plugin/marketplace.json +0 -85
  169. package/adapters/chatgpt/openapi.yaml +0 -1695
  170. package/scripts/bot-detector.js +0 -50
@@ -2,16 +2,42 @@
2
2
  'use strict';
3
3
 
4
4
  const path = require('path');
5
- const { isProLicensed } = require('./license');
5
+ const { isProLicensed, isValidKey } = require('./license');
6
6
 
7
7
  function getStatuslineMeta(options = {}) {
8
8
  const pkg = require(path.join(__dirname, '..', 'package.json'));
9
9
  const env = options.env || process.env;
10
10
  const homeDir = options.homeDir || env.HOME || env.USERPROFILE || '.';
11
+ const fs = require('fs');
12
+
13
+ // Enterprise detection based on key prefix
14
+ let apiKey = env.THUMBGATE_OPERATOR_KEY || env.THUMBGATE_API_KEY || '';
15
+ if (apiKey && !isValidKey(apiKey)) {
16
+ apiKey = '';
17
+ }
18
+
19
+ // Fallback to reading from disk if not in env
20
+ if (!apiKey) {
21
+ try {
22
+ const configPath = path.join(homeDir, '.config', 'thumbgate', 'operator.json');
23
+ if (fs.existsSync(configPath)) {
24
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
25
+ apiKey = config.operatorKey || config.apiKey || '';
26
+ }
27
+ } catch (_) { /* ignore disk read errors */ }
28
+ }
29
+
30
+ let activeTier = 'Free';
31
+
32
+ if (apiKey.startsWith('tg_op_') || apiKey.startsWith('tg_creator_')) {
33
+ activeTier = 'Enterprise';
34
+ } else if (isProLicensed({ homeDir }) || apiKey.startsWith('tg_pro_')) {
35
+ activeTier = 'Pro';
36
+ }
11
37
 
12
38
  return {
13
39
  version: String(pkg.version || '').trim() || 'unknown',
14
- tier: isProLicensed({ homeDir }) ? 'Pro' : 'Free',
40
+ tier: activeTier,
15
41
  };
16
42
  }
17
43
 
@@ -7,6 +7,7 @@
7
7
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
8
8
  case "$SCRIPT_DIR" in *[!a-zA-Z0-9/_.-]*) echo "ThumbGate: invalid script path"; exit 1;; esac
9
9
  LOCAL_API_ORIGIN="${THUMBGATE_LOCAL_API_ORIGIN:-http://localhost:3456}"
10
+ STATUSLINE_VERBOSE="${THUMBGATE_STATUSLINE_VERBOSE:-0}"
10
11
 
11
12
  # ── Parse Claude Code session JSON from stdin ─────────────────────
12
13
  eval "$(cat | jq -r '
@@ -44,7 +45,19 @@ if [ -z "$THUMBGATE_CACHE" ]; then
44
45
  fi
45
46
 
46
47
  UP="0"; DOWN="0"; LESSONS="0"; TREND="?"; CACHE_TS="0"
47
- if [ -f "$THUMBGATE_CACHE" ]; then
48
+ # Display reads aggregate across every per-folder cache so the statusline
49
+ # reflects true totals, not whichever folder happens to be cwd. Writes still
50
+ # target $THUMBGATE_CACHE (per-folder), so attribution is preserved.
51
+ _RESOLVED_CACHE_JSON=$(node "${SCRIPT_DIR}/statusline-cache-read.js" 2>/dev/null)
52
+ if [[ -n "$_RESOLVED_CACHE_JSON" ]]; then
53
+ eval "$(echo "$_RESOLVED_CACHE_JSON" | jq -r '
54
+ @sh "UP=\(.thumbs_up // "0")",
55
+ @sh "DOWN=\(.thumbs_down // "0")",
56
+ @sh "LESSONS=\(.lessons // "0")",
57
+ @sh "TREND=\(.trend // "?")",
58
+ @sh "CACHE_TS=\(.updated_at // "0")"
59
+ ' 2>/dev/null)"
60
+ elif [[ -f "$THUMBGATE_CACHE" ]]; then
48
61
  eval "$(jq -r '
49
62
  @sh "UP=\(.thumbs_up // "0")",
50
63
  @sh "DOWN=\(.thumbs_down // "0")",
@@ -199,8 +212,10 @@ LINE="${LINE:+${LINE} · }ThumbGate v${TG_VERSION} · ${TG_TIER}"
199
212
  if [[ "$UP" = "0" && "$DOWN" = "0" ]]; then
200
213
  LINE="${D}${LINE}${RST} · no feedback yet"
201
214
  [[ -n "$PR_LABEL" ]] && LINE="${LINE} · ${D}${PR_LABEL}${RST}"
202
- LINE="${LINE} · ${C}${DASHBOARD_LINK}${RST} · ${M}${LESSONS_LINK}${RST}"
215
+
216
+ LINE="${LINE} · ${C}${DASHBOARD_LINK}${RST}"
203
217
  [[ -n "$LATEST_LESSON_LINK" ]] && LINE="${LINE} · ${D}${LATEST_LESSON_LINK}${RST}"
218
+
204
219
  printf '%b\n' "$LINE"
205
220
  else
206
221
  LINE="${LINE} · ${G}${BD}${UP}${RST}${UP_LINK} ${R}${BD}${DOWN}${RST}${DOWN_LINK} ${ARROW}"
@@ -210,8 +225,9 @@ else
210
225
  [[ "${AT_RISK:-0}" -gt 0 ]] && LINE="${LINE} ${R}${AT_RISK}⚠${RST}"
211
226
  [[ "${ANOMALIES:-0}" -gt 0 ]] && LINE="${LINE} ${R}${ANOMALIES}☠${RST}"
212
227
  [[ -n "$PR_LABEL" ]] && LINE="${LINE} · ${D}${PR_LABEL}${RST}"
213
- LINE="${LINE} · ${C}${DASHBOARD_LINK}${RST} · ${M}${LESSONS_LINK}${RST}"
228
+
229
+ LINE="${LINE} · ${C}${DASHBOARD_LINK}${RST}"
214
230
  [[ -n "$LATEST_LESSON_LINK" ]] && LINE="${LINE} · ${D}${LATEST_LESSON_LINK}${RST}"
215
-
231
+
216
232
  printf '%b\n' "$LINE"
217
233
  fi
@@ -0,0 +1,374 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * sync-telemetry-from-prod.js
6
+ *
7
+ * The agentic data pipeline (scripts/agentic-data-pipeline.js →
8
+ * scripts/telemetry-analytics.js) computes `get_business_metrics` from the LOCAL
9
+ * telemetry store at `<feedbackDir>/telemetry-pings.jsonl`. On a developer
10
+ * machine that store is near-empty, so metrics read uniqueVisitors:0 /
11
+ * checkoutStarts:0 even though real web traffic is flowing.
12
+ *
13
+ * The real web funnel lives on the PROD Railway volume and is exposed only via
14
+ * the operator-key-gated endpoint `GET /v1/telemetry/export`. Both stores use the
15
+ * SAME jsonl format/filename and matching fields (receivedAt/event/eventType/
16
+ * installId/visitorId/sessionId/traceId), so a sync is a clean append + dedupe.
17
+ *
18
+ * This script:
19
+ * 1. Resolves the operator (or admin) key from env or ~/.config/thumbgate/operator.json.
20
+ * 2. GETs /v1/telemetry/export?source=both with `Authorization: Bearer <key>`.
21
+ * 3. Merges/dedupes the returned `telemetry` rows into
22
+ * `<feedbackDir>/telemetry-pings.jsonl` (dedupe by a stable sha256 of the
23
+ * canonical row, so repeat runs never double-count).
24
+ * 4. Optionally (`--funnel`) persists the returned `funnel` rows into
25
+ * `<feedbackDir>/funnel-events.jsonl` the same way.
26
+ *
27
+ * After a run, `get_business_metrics` reflects the real funnel because the
28
+ * pipeline reads the same feedback dir on each call.
29
+ *
30
+ * SECURITY: the operator key is never printed. Only its source is reported.
31
+ *
32
+ * Usage:
33
+ * node scripts/sync-telemetry-from-prod.js [--since-days=30] [--since=<iso>]
34
+ * [--limit=10000] [--source=both|telemetry|funnel] [--funnel]
35
+ * [--base-url=<url>] [--feedback-dir=<dir>] [--json] [--dry-run]
36
+ */
37
+
38
+ const fs = require('node:fs');
39
+ const os = require('node:os');
40
+ const path = require('node:path');
41
+ const crypto = require('node:crypto');
42
+
43
+ const { getFeedbackPaths } = require('./feedback-loop');
44
+
45
+ const DEFAULT_BASE_URL = 'https://thumbgate-production.up.railway.app';
46
+ const OPERATOR_CONFIG_PATH = path.join(os.homedir(), '.config', 'thumbgate', 'operator.json');
47
+ const TELEMETRY_FILE_NAME = 'telemetry-pings.jsonl';
48
+ const FUNNEL_FILE_NAME = 'funnel-events.jsonl';
49
+ const EXPORT_PATH = '/v1/telemetry/export';
50
+ const HARD_LIMIT = 10000; // server clamps each stream to this
51
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
52
+
53
+ // Configure fetch proxy when running behind a corporate/sandbox proxy. Mirrors
54
+ // scripts/operational-summary.js so hosted reads behave the same everywhere.
55
+ (function configureProxy() {
56
+ const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy
57
+ || process.env.HTTP_PROXY || process.env.http_proxy;
58
+ if (!proxyUrl) return;
59
+ try {
60
+ const { ProxyAgent, setGlobalDispatcher } = require('undici');
61
+ setGlobalDispatcher(new ProxyAgent(proxyUrl));
62
+ } catch {
63
+ // undici not available — fetch will use the default dispatcher.
64
+ }
65
+ }());
66
+
67
+ function normalizeText(value) {
68
+ if (value === undefined || value === null) return null;
69
+ const text = String(value).trim();
70
+ return text || null;
71
+ }
72
+
73
+ function loadOperatorConfig(configPath = OPERATOR_CONFIG_PATH) {
74
+ try {
75
+ const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
76
+ return {
77
+ operatorKey: normalizeText(parsed.operatorKey),
78
+ baseUrl: normalizeText(parsed.baseUrl),
79
+ };
80
+ } catch {
81
+ return { operatorKey: null, baseUrl: null };
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Resolve the credential + base URL the same way scripts/operational-summary.js
87
+ * does: env THUMBGATE_OPERATOR_KEY > operator.json operatorKey > env
88
+ * THUMBGATE_API_KEY (admin keys are an accepted alternate auth path on the
89
+ * export endpoint). `source` records WHERE the key came from for diagnostics —
90
+ * the key value itself is never returned to callers that print it.
91
+ */
92
+ function resolveOperatorConfig(options = {}) {
93
+ const operatorConfig = loadOperatorConfig(options.configPath);
94
+ let apiKey = normalizeText(process.env.THUMBGATE_OPERATOR_KEY);
95
+ let keySource = apiKey ? 'env:THUMBGATE_OPERATOR_KEY' : null;
96
+ if (!apiKey && operatorConfig.operatorKey) {
97
+ apiKey = operatorConfig.operatorKey;
98
+ keySource = 'operator.json';
99
+ }
100
+ if (!apiKey) {
101
+ apiKey = normalizeText(process.env.THUMBGATE_API_KEY);
102
+ keySource = apiKey ? 'env:THUMBGATE_API_KEY' : null;
103
+ }
104
+ const baseUrl = normalizeText(options.baseUrl)
105
+ || normalizeText(process.env.THUMBGATE_SYNC_BASE_URL)
106
+ || normalizeText(process.env.THUMBGATE_BILLING_API_BASE_URL)
107
+ || operatorConfig.baseUrl
108
+ || DEFAULT_BASE_URL;
109
+ return { apiKey, keySource, baseUrl };
110
+ }
111
+
112
+ // Deterministic, LOCALE-INDEPENDENT key comparator (UTF-16 code-unit order).
113
+ // Do NOT switch this to String.prototype.localeCompare (what SonarCloud S2871
114
+ // suggests): localeCompare is locale-sensitive, so it would make the canonical
115
+ // serialization — and therefore the dedupe hash below — differ across machines
116
+ // and CI runners, silently breaking cross-environment dedupe.
117
+ function compareKeys(a, b) {
118
+ if (a < b) return -1;
119
+ if (a > b) return 1;
120
+ return 0;
121
+ }
122
+
123
+ // Recursively sort object keys so structurally-identical rows serialize
124
+ // identically regardless of original key order.
125
+ function canonicalize(value) {
126
+ if (Array.isArray(value)) return value.map(canonicalize);
127
+ if (value && typeof value === 'object') {
128
+ const out = {};
129
+ for (const key of Object.keys(value).sort(compareKeys)) out[key] = canonicalize(value[key]);
130
+ return out;
131
+ }
132
+ return value;
133
+ }
134
+
135
+ // Stable dedupe id. sha256 (not sha1/md5 — SonarCloud S4790) over the canonical
136
+ // JSON of the whole row: identical rows → identical id across runs.
137
+ function stableRowId(row) {
138
+ return crypto.createHash('sha256').update(JSON.stringify(canonicalize(row))).digest('hex');
139
+ }
140
+
141
+ // The export endpoint returns each stream as { rows: [...] }; older/simplified
142
+ // shapes may return a bare array. Accept both.
143
+ function extractRows(streamValue) {
144
+ if (Array.isArray(streamValue)) return streamValue;
145
+ if (streamValue && Array.isArray(streamValue.rows)) return streamValue.rows;
146
+ return [];
147
+ }
148
+
149
+ /**
150
+ * Fetch the telemetry export. `fetchImpl` is injectable so tests run without
151
+ * network or secrets. Auth is `Authorization: Bearer <key>` — the server's
152
+ * extractApiKey reads Bearer / x-api-key, NOT x-operator-key.
153
+ */
154
+ async function fetchTelemetryExport(params = {}) {
155
+ const {
156
+ baseUrl,
157
+ operatorKey,
158
+ since,
159
+ limit = HARD_LIMIT,
160
+ source = 'both',
161
+ fetchImpl = globalThis.fetch,
162
+ } = params;
163
+ if (!operatorKey) {
164
+ const err = new Error('No operator/admin key available for telemetry export.');
165
+ err.code = 'no_key';
166
+ throw err;
167
+ }
168
+ if (typeof fetchImpl !== 'function') {
169
+ const err = new Error('No fetch implementation available (Node >=18 or inject fetchImpl).');
170
+ err.code = 'no_fetch';
171
+ throw err;
172
+ }
173
+ const url = new URL(EXPORT_PATH, baseUrl || DEFAULT_BASE_URL);
174
+ url.searchParams.set('source', source);
175
+ url.searchParams.set('limit', String(Math.min(Math.max(1, Number(limit) || HARD_LIMIT), HARD_LIMIT)));
176
+ if (since) url.searchParams.set('since', since);
177
+
178
+ const response = await fetchImpl(url, {
179
+ method: 'GET',
180
+ headers: {
181
+ authorization: `Bearer ${operatorKey}`,
182
+ accept: 'application/json',
183
+ },
184
+ });
185
+ if (!response.ok) {
186
+ const bodyText = await response.text().catch(() => '');
187
+ const err = new Error(`Telemetry export failed: HTTP ${response.status} ${bodyText.slice(0, 200)}`);
188
+ err.code = 'http_error';
189
+ err.status = response.status;
190
+ throw err;
191
+ }
192
+ return response.json();
193
+ }
194
+
195
+ // Read every stable id already present in a jsonl ledger (skips unparseable lines).
196
+ function readExistingIds(filePath) {
197
+ const ids = new Set();
198
+ if (!fs.existsSync(filePath)) return ids;
199
+ const text = fs.readFileSync(filePath, 'utf8');
200
+ for (const line of text.split('\n')) {
201
+ if (!line.trim()) continue;
202
+ try {
203
+ ids.add(stableRowId(JSON.parse(line)));
204
+ } catch {
205
+ // Tolerate malformed historical lines.
206
+ }
207
+ }
208
+ return ids;
209
+ }
210
+
211
+ /**
212
+ * Append only rows whose stable id isn't already present. Pure aside from the
213
+ * single append; safe to re-run. Returns { added, skipped }.
214
+ */
215
+ function mergeRowsIntoLedger(filePath, rows, options = {}) {
216
+ const existing = options.existingIds || readExistingIds(filePath);
217
+ const toAppend = [];
218
+ let skipped = 0;
219
+ for (const row of rows) {
220
+ if (!row || typeof row !== 'object') { skipped += 1; continue; }
221
+ const id = stableRowId(row);
222
+ if (existing.has(id)) { skipped += 1; continue; }
223
+ existing.add(id);
224
+ toAppend.push(JSON.stringify(row));
225
+ }
226
+ if (toAppend.length && !options.dryRun) {
227
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
228
+ fs.appendFileSync(filePath, toAppend.map((l) => `${l}\n`).join(''), 'utf8');
229
+ }
230
+ return { added: toAppend.length, skipped };
231
+ }
232
+
233
+ function resolveFeedbackDir(options = {}) {
234
+ if (options.feedbackDir) return path.resolve(options.feedbackDir);
235
+ return getFeedbackPaths().FEEDBACK_DIR;
236
+ }
237
+
238
+ // Resolve the `since` ISO timestamp: an explicit --since wins; otherwise derive
239
+ // it from --since-days relative to nowMs (test-injectable) or the current time.
240
+ function computeSinceIso(options = {}) {
241
+ if (options.since) return options.since;
242
+ if (!Number.isFinite(options.sinceDays)) return undefined;
243
+ const baseMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
244
+ return new Date(baseMs - options.sinceDays * MS_PER_DAY).toISOString();
245
+ }
246
+
247
+ /**
248
+ * End-to-end sync: fetch export → merge telemetry (always) and funnel (opt-in).
249
+ * Returns a summary object with counts and resolved paths (no secrets).
250
+ */
251
+ async function syncTelemetryFromProd(options = {}) {
252
+ const config = resolveOperatorConfig(options);
253
+ const feedbackDir = resolveFeedbackDir(options);
254
+ const source = options.source || (options.funnel ? 'both' : 'telemetry');
255
+ const sinceIso = computeSinceIso(options);
256
+
257
+ const payload = await fetchTelemetryExport({
258
+ baseUrl: config.baseUrl,
259
+ operatorKey: config.apiKey,
260
+ since: sinceIso,
261
+ limit: options.limit || HARD_LIMIT,
262
+ source,
263
+ fetchImpl: options.fetchImpl,
264
+ });
265
+
266
+ const telemetryRows = extractRows(payload.telemetry);
267
+ const telemetryPath = path.join(feedbackDir, TELEMETRY_FILE_NAME);
268
+ const telemetryResult = mergeRowsIntoLedger(telemetryPath, telemetryRows, { dryRun: options.dryRun });
269
+
270
+ const summary = {
271
+ feedbackDir,
272
+ baseUrl: config.baseUrl,
273
+ keySource: config.keySource,
274
+ source,
275
+ since: payload.since || sinceIso || null,
276
+ dryRun: !!options.dryRun,
277
+ telemetry: {
278
+ path: telemetryPath,
279
+ fetched: telemetryRows.length,
280
+ added: telemetryResult.added,
281
+ skipped: telemetryResult.skipped,
282
+ },
283
+ funnel: null,
284
+ };
285
+
286
+ if (options.funnel) {
287
+ const funnelRows = extractRows(payload.funnel);
288
+ const funnelPath = path.join(feedbackDir, FUNNEL_FILE_NAME);
289
+ const funnelResult = mergeRowsIntoLedger(funnelPath, funnelRows, { dryRun: options.dryRun });
290
+ summary.funnel = {
291
+ path: funnelPath,
292
+ fetched: funnelRows.length,
293
+ added: funnelResult.added,
294
+ skipped: funnelResult.skipped,
295
+ };
296
+ }
297
+
298
+ return summary;
299
+ }
300
+
301
+ function parseArgs(argv) {
302
+ const opts = { sinceDays: 30, funnel: false, json: false, dryRun: false };
303
+ for (const arg of argv) {
304
+ if (arg === '--funnel') opts.funnel = true;
305
+ else if (arg === '--json') opts.json = true;
306
+ else if (arg === '--dry-run') opts.dryRun = true;
307
+ else if (arg.startsWith('--since-days=')) opts.sinceDays = Number(arg.split('=')[1]);
308
+ else if (arg.startsWith('--since=')) opts.since = arg.split('=').slice(1).join('=');
309
+ else if (arg.startsWith('--limit=')) opts.limit = Number(arg.split('=')[1]);
310
+ else if (arg.startsWith('--source=')) opts.source = arg.split('=')[1];
311
+ else if (arg.startsWith('--base-url=')) opts.baseUrl = arg.split('=').slice(1).join('=');
312
+ else if (arg.startsWith('--feedback-dir=')) opts.feedbackDir = arg.split('=').slice(1).join('=');
313
+ }
314
+ if (!Number.isFinite(opts.sinceDays)) opts.sinceDays = 30;
315
+ return opts;
316
+ }
317
+
318
+ async function main() {
319
+ const opts = parseArgs(process.argv.slice(2));
320
+ const preflight = resolveOperatorConfig(opts);
321
+ if (!preflight.apiKey) {
322
+ console.error(
323
+ 'No operator/admin key found. Set THUMBGATE_OPERATOR_KEY (or THUMBGATE_API_KEY), '
324
+ + 'or add operatorKey to ~/.config/thumbgate/operator.json. '
325
+ + 'The key lives in Railway prod env as THUMBGATE_OPERATOR_KEY.'
326
+ );
327
+ process.exitCode = 1;
328
+ return;
329
+ }
330
+ try {
331
+ const summary = await syncTelemetryFromProd(opts);
332
+ if (opts.json) {
333
+ console.log(JSON.stringify(summary, null, 2));
334
+ } else {
335
+ const t = summary.telemetry;
336
+ console.log(`✅ Telemetry sync${summary.dryRun ? ' (dry-run)' : ''} complete`);
337
+ console.log(` source=${summary.source} since=${summary.since || 'server-default'} via ${summary.keySource}`);
338
+ console.log(` telemetry: ${t.fetched} fetched → ${t.added} added, ${t.skipped} already present`);
339
+ console.log(` → ${t.path}`);
340
+ if (summary.funnel) {
341
+ const f = summary.funnel;
342
+ console.log(` funnel: ${f.fetched} fetched → ${f.added} added, ${f.skipped} already present`);
343
+ console.log(` → ${f.path}`);
344
+ }
345
+ }
346
+ } catch (err) {
347
+ console.error(`❌ Telemetry sync failed: ${err.message}`);
348
+ process.exitCode = 1;
349
+ }
350
+ }
351
+
352
+ // Path-based entrypoint check (require.main === module is flagged by SonarCloud S3403).
353
+ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(__filename)) {
354
+ main();
355
+ }
356
+
357
+ module.exports = {
358
+ DEFAULT_BASE_URL,
359
+ TELEMETRY_FILE_NAME,
360
+ FUNNEL_FILE_NAME,
361
+ HARD_LIMIT,
362
+ canonicalize,
363
+ stableRowId,
364
+ extractRows,
365
+ loadOperatorConfig,
366
+ resolveOperatorConfig,
367
+ fetchTelemetryExport,
368
+ readExistingIds,
369
+ mergeRowsIntoLedger,
370
+ resolveFeedbackDir,
371
+ computeSinceIso,
372
+ syncTelemetryFromProd,
373
+ parseArgs,
374
+ };