titan-agent 6.1.0-beta.1 → 6.1.0-beta.2
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/README.md +1 -1
- package/dist/analytics/consent.js +6 -0
- package/dist/analytics/consent.js.map +1 -0
- package/dist/analytics/posthog.js +96 -46
- package/dist/analytics/posthog.js.map +1 -1
- package/dist/cli/index.js +58 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/reconsent.js +90 -0
- package/dist/cli/reconsent.js.map +1 -0
- package/dist/config/schema.js +12 -1
- package/dist/config/schema.js.map +1 -1
- package/dist/utils/constants.js +1 -1
- package/dist/utils/constants.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
|
|
47
47
|
## 🪵 NEW in v6.1 — Mission Chat + Desk view
|
|
48
48
|
|
|
49
|
-
> Status: `v6.1.0-beta.
|
|
49
|
+
> Status: `v6.1.0-beta.2` — live on npm at `@latest` and `@beta`. Alpha series concluded with `6.1.0-alpha.57`; the schema promoted to beta once features stabilized.
|
|
50
50
|
> Install with `npm i -g titan-agent` (or `npm update -g titan-agent`).
|
|
51
51
|
> The v6.0 "Presence" feature set below still applies — v6.1.0 layers
|
|
52
52
|
> a beautiful new surface on top of it.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/analytics/consent.ts"],"sourcesContent":["/**\n * TITAN — Telemetry Consent Version\n *\n * v6.1.0 Phase B (`6.1.0-beta.2`) — re-consent flow.\n *\n * The `REQUIRED_CONSENT_VERSION` constant is bumped each time the\n * telemetry payload or destination changes in a way that a previously-\n * consenting user should be re-prompted about. When a user's stored\n * `config.telemetry.consentVersion` is **less than** this number, the\n * CLI must re-prompt them (and default to OFF on any non-affirmative\n * answer — including Ctrl-C / blank / \"no\" / anything else).\n *\n * History\n * -------\n * 1 — v5.x SetupWizard opt-in (legacy `consentedAt` / `consentedVersion`).\n * 2 — v6.0+ payload: install ID + bucketed system fingerprint + feature\n * flags + scrubbed crash stacks. Routes to PostHog Cloud (US or EU\n * by IANA timezone). `posthog-node` SDK used instead of hand-rolled\n * fetch. Local 30-day TTL on `~/.titan/bug-reports.jsonl`.\n *\n * Don't bump this number lightly — every bump re-prompts every existing\n * telemetry user and defaults them to OFF if they dismiss. Only bump\n * when the change is material enough that informed consent has expired\n * (new destination, new payload shape, new retention rules, etc.).\n */\nexport const REQUIRED_CONSENT_VERSION = 2;\n"],"mappings":";AAyBO,MAAM,2BAA2B;","names":[]}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { loadConfig } from "../config/config.js";
|
|
3
3
|
import logger from "../utils/logger.js";
|
|
4
|
+
import { redactSecrets } from "../security/secretGuard.js";
|
|
5
|
+
import { REQUIRED_CONSENT_VERSION } from "./consent.js";
|
|
4
6
|
const COMPONENT = "PostHog";
|
|
5
|
-
let cachedKey;
|
|
6
|
-
let cachedHost;
|
|
7
7
|
const EU_TIMEZONE_REGEX = /^Europe\//;
|
|
8
8
|
const EU_ADJACENT_ZONES = /* @__PURE__ */ new Set([
|
|
9
9
|
"Atlantic/Azores",
|
|
@@ -28,64 +28,108 @@ function resolvePostHogHost(now) {
|
|
|
28
28
|
}
|
|
29
29
|
return "https://us.i.posthog.com";
|
|
30
30
|
}
|
|
31
|
+
function isHardDisabledByEnv() {
|
|
32
|
+
const v = process.env.TITAN_TELEMETRY_DISABLED;
|
|
33
|
+
return v === "1" || v === "true";
|
|
34
|
+
}
|
|
31
35
|
function getPostHogConfig() {
|
|
36
|
+
if (isHardDisabledByEnv()) return void 0;
|
|
32
37
|
const cfg = loadConfig();
|
|
33
38
|
const tel = cfg.telemetry;
|
|
34
39
|
if (!tel?.enabled) return void 0;
|
|
40
|
+
const consentVersion = typeof tel.consentVersion === "number" ? tel.consentVersion : 0;
|
|
41
|
+
if (consentVersion < REQUIRED_CONSENT_VERSION) return void 0;
|
|
35
42
|
const apiKey = process.env.TITAN_POSTHOG_KEY || tel.posthogApiKey || void 0;
|
|
36
43
|
if (!apiKey) return void 0;
|
|
37
44
|
const host = process.env.TITAN_POSTHOG_HOST || tel.posthogHost || resolvePostHogHost();
|
|
38
45
|
return { apiKey, host };
|
|
39
46
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
},
|
|
52
|
-
timestamp: timestamp || (/* @__PURE__ */ new Date()).toISOString()
|
|
47
|
+
let _client = null;
|
|
48
|
+
let _shutdownHooksInstalled = false;
|
|
49
|
+
function installShutdownHooks() {
|
|
50
|
+
if (_shutdownHooksInstalled) return;
|
|
51
|
+
_shutdownHooksInstalled = true;
|
|
52
|
+
const shutdown = async () => {
|
|
53
|
+
if (!_client) return;
|
|
54
|
+
try {
|
|
55
|
+
await _client.shutdown();
|
|
56
|
+
} catch {
|
|
57
|
+
}
|
|
53
58
|
};
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
body: JSON.stringify(body),
|
|
58
|
-
signal: AbortSignal.timeout(8e3)
|
|
59
|
-
});
|
|
60
|
-
if (!res.ok) {
|
|
61
|
-
const text = await res.text().catch(() => "");
|
|
62
|
-
throw new Error(`PostHog HTTP ${res.status}: ${text.slice(0, 200)}`);
|
|
63
|
-
}
|
|
59
|
+
process.on("SIGTERM", shutdown);
|
|
60
|
+
process.on("SIGINT", shutdown);
|
|
61
|
+
process.on("beforeExit", shutdown);
|
|
64
62
|
}
|
|
65
|
-
async function
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
63
|
+
async function getClient() {
|
|
64
|
+
const cfg = getPostHogConfig();
|
|
65
|
+
if (!cfg) return null;
|
|
66
|
+
if (_client) return { client: _client, apiKey: cfg.apiKey, host: cfg.host };
|
|
67
|
+
const mod = await import("posthog-node");
|
|
68
|
+
const PostHog = mod.PostHog;
|
|
69
|
+
_client = new PostHog(cfg.apiKey, {
|
|
70
|
+
host: cfg.host,
|
|
71
|
+
// CLI-tuned: TITAN commands are usually short-lived. The SDK
|
|
72
|
+
// defaults (`flushAt: 20`, `flushInterval: 10000`) would delay
|
|
73
|
+
// events past process exit.
|
|
74
|
+
flushAt: 1,
|
|
75
|
+
flushInterval: 0,
|
|
76
|
+
// Explicitly pin geoip off — the SDK default is true but we want
|
|
77
|
+
// a hard guarantee that no IP-based geolocation is happening on
|
|
78
|
+
// our anonymous events. EU/US routing is already handled via
|
|
79
|
+
// `resolvePostHogHost()` from the local IANA timezone.
|
|
80
|
+
disableGeoip: true
|
|
81
|
+
});
|
|
82
|
+
installShutdownHooks();
|
|
83
|
+
return { client: _client, apiKey: cfg.apiKey, host: cfg.host };
|
|
69
84
|
}
|
|
70
85
|
const status = { enabled: false, sentCount: 0, failedCount: 0 };
|
|
71
86
|
function getPostHogStatus() {
|
|
72
87
|
return { ...status };
|
|
73
88
|
}
|
|
89
|
+
function scrubProps(props) {
|
|
90
|
+
const out = {};
|
|
91
|
+
for (const [k, v] of Object.entries(props)) {
|
|
92
|
+
if (typeof v === "string") {
|
|
93
|
+
out[k] = redactSecrets(v);
|
|
94
|
+
} else if (Array.isArray(v)) {
|
|
95
|
+
out[k] = v.map((item) => typeof item === "string" ? redactSecrets(item) : item);
|
|
96
|
+
} else {
|
|
97
|
+
out[k] = v;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
74
102
|
async function sendPostHogEvent(payload) {
|
|
75
|
-
const
|
|
76
|
-
if (!
|
|
103
|
+
const handle = await getClient();
|
|
104
|
+
if (!handle) {
|
|
77
105
|
status.enabled = false;
|
|
78
106
|
return;
|
|
79
107
|
}
|
|
80
108
|
status.enabled = true;
|
|
81
|
-
const {
|
|
109
|
+
const { client } = handle;
|
|
82
110
|
const distinctId = typeof payload.installId === "string" ? payload.installId : "unknown";
|
|
83
111
|
const type = typeof payload.type === "string" ? payload.type : "unknown";
|
|
84
112
|
const timestamp = typeof payload.timestamp === "string" ? payload.timestamp : typeof payload.collectedAt === "string" ? payload.collectedAt : void 0;
|
|
85
|
-
const
|
|
113
|
+
const tsDate = timestamp ? new Date(timestamp) : /* @__PURE__ */ new Date();
|
|
86
114
|
try {
|
|
115
|
+
const { TITAN_VERSION } = await import("../utils/constants.js");
|
|
116
|
+
const baseProps = {
|
|
117
|
+
// Anonymous-by-default — no person profile created.
|
|
118
|
+
// Switch to identified capture (with `$set` / `$set_once`)
|
|
119
|
+
// only after an explicit `titan login` flow.
|
|
120
|
+
$process_person_profile: false,
|
|
121
|
+
$lib: "titan-analytics",
|
|
122
|
+
$lib_version: TITAN_VERSION
|
|
123
|
+
};
|
|
124
|
+
const send = (event, props) => {
|
|
125
|
+
client.capture({
|
|
126
|
+
distinctId,
|
|
127
|
+
event,
|
|
128
|
+
properties: scrubProps({ ...baseProps, ...props }),
|
|
129
|
+
timestamp: tsDate
|
|
130
|
+
});
|
|
131
|
+
};
|
|
87
132
|
if (type === "system_profile") {
|
|
88
|
-
const personProps = {};
|
|
89
133
|
const allowlist = [
|
|
90
134
|
"os",
|
|
91
135
|
"osRelease",
|
|
@@ -99,14 +143,11 @@ async function sendPostHogEvent(payload) {
|
|
|
99
143
|
"version",
|
|
100
144
|
"nodeVersion"
|
|
101
145
|
];
|
|
146
|
+
const props = {};
|
|
102
147
|
for (const key of allowlist) {
|
|
103
|
-
if (key in payload)
|
|
148
|
+
if (key in payload) props[key] = payload[key];
|
|
104
149
|
}
|
|
105
|
-
|
|
106
|
-
await capture(apiKey, host, distinctId, "system_profile", {
|
|
107
|
-
version: payload.version,
|
|
108
|
-
installMethod: payload.installMethod
|
|
109
|
-
}, ts);
|
|
150
|
+
send("system_profile", props);
|
|
110
151
|
} else if (type === "heartbeat") {
|
|
111
152
|
const props = {
|
|
112
153
|
uptime_seconds: payload.uptimeSeconds,
|
|
@@ -119,22 +160,26 @@ async function sendPostHogEvent(payload) {
|
|
|
119
160
|
props[`feature_${k}`] = v;
|
|
120
161
|
}
|
|
121
162
|
}
|
|
122
|
-
|
|
163
|
+
send("heartbeat", props);
|
|
123
164
|
} else if (type === "install" || type === "update") {
|
|
124
|
-
|
|
165
|
+
send(type, {
|
|
125
166
|
version: payload.version,
|
|
126
167
|
from_version: payload.fromVersion,
|
|
127
168
|
install_method: payload.installMethod
|
|
128
|
-
}
|
|
169
|
+
});
|
|
129
170
|
} else if (type === "error") {
|
|
130
|
-
|
|
171
|
+
send("error", {
|
|
131
172
|
error_type: payload.errorType,
|
|
132
173
|
message: payload.message,
|
|
133
174
|
version: payload.version
|
|
134
|
-
}
|
|
175
|
+
});
|
|
135
176
|
} else {
|
|
136
177
|
const { type: _type, installId: _installId, ...rest } = payload;
|
|
137
|
-
|
|
178
|
+
send(type, rest);
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
await client.flush?.();
|
|
182
|
+
} catch {
|
|
138
183
|
}
|
|
139
184
|
status.sentCount += 1;
|
|
140
185
|
status.lastSuccessAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -145,7 +190,12 @@ async function sendPostHogEvent(payload) {
|
|
|
145
190
|
logger.debug(COMPONENT, `PostHog send failed: ${status.lastError}`);
|
|
146
191
|
}
|
|
147
192
|
}
|
|
193
|
+
function _resetPostHogClientForTests() {
|
|
194
|
+
_client = null;
|
|
195
|
+
_shutdownHooksInstalled = false;
|
|
196
|
+
}
|
|
148
197
|
export {
|
|
198
|
+
_resetPostHogClientForTests,
|
|
149
199
|
getPostHogStatus,
|
|
150
200
|
resolvePostHogHost,
|
|
151
201
|
sendPostHogEvent
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/analytics/posthog.ts"],"sourcesContent":["/**\n * TITAN — PostHog Analytics Bridge\n * Sends opt-in telemetry events to PostHog Cloud (or self-hosted).\n * Only active when telemetry.enabled=true AND posthogApiKey is configured.\n */\nimport { loadConfig } from '../config/config.js';\nimport logger from '../utils/logger.js';\n\nconst COMPONENT = 'PostHog';\n\n// In-memory cache for config-derived client state\nlet cachedKey: string | undefined;\nlet cachedHost: string | undefined;\n\n/**\n * v6.1.0-beta.1 — env-var override + EU/US auto-detection.\n *\n * Precedence (highest first):\n * 1. `process.env.TITAN_POSTHOG_KEY` → overrides project key\n * 2. `process.env.TITAN_POSTHOG_HOST` → overrides ingest host\n * 3. `telemetry.posthogApiKey` / `telemetry.posthogHost` from config\n * 4. EU/US auto-detection via IANA timezone (NO network / NO IP lookup —\n * privacy-safe + offline-safe)\n * 5. US default\n *\n * EU detection (GDPR data-residency):\n * `Intl.DateTimeFormat().resolvedOptions().timeZone` is consulted; if it\n * starts with `Europe/`, or matches one of the EU-adjacent Atlantic /\n * Africa zones (Azores, Canary, Faroe, Madeira, Reykjavik, Ceuta) the\n * host resolves to `https://eu.i.posthog.com` (PostHog Cloud Frankfurt).\n * Everything else routes to the US.\n *\n * Rationale: timezone is offline-resolvable, requires zero pre-consent\n * network calls, and is good-enough for \"is this user EU?\" at the\n * data-residency level. IP-based geolocation would be more precise but\n * costs a privacy hit + a network round-trip BEFORE consent is granted\n * — exactly what GDPR is trying to prevent.\n */\nconst EU_TIMEZONE_REGEX = /^Europe\\//;\nconst EU_ADJACENT_ZONES = new Set([\n 'Atlantic/Azores',\n 'Atlantic/Canary',\n 'Atlantic/Faroe',\n 'Atlantic/Madeira',\n 'Atlantic/Reykjavik',\n 'Africa/Ceuta',\n]);\n\nexport function resolvePostHogHost(now?: { timeZone?: string }): string {\n if (process.env.TITAN_POSTHOG_HOST) return process.env.TITAN_POSTHOG_HOST;\n const tz = (() => {\n if (now?.timeZone) return now.timeZone;\n try { return Intl.DateTimeFormat().resolvedOptions().timeZone; }\n catch { return ''; }\n })();\n if (EU_TIMEZONE_REGEX.test(tz) || EU_ADJACENT_ZONES.has(tz)) {\n return 'https://eu.i.posthog.com';\n }\n return 'https://us.i.posthog.com';\n}\n\nfunction getPostHogConfig(): { apiKey: string; host: string } | undefined {\n const cfg = loadConfig();\n const tel = cfg.telemetry as Record<string, unknown> | undefined;\n if (!tel?.enabled) return undefined;\n const apiKey = process.env.TITAN_POSTHOG_KEY || (tel.posthogApiKey as string) || undefined;\n if (!apiKey) return undefined;\n // env > config-explicit > auto-detected (EU vs US via timezone) > US default\n // NOTE: env var must be checked here too — the test \"TITAN_POSTHOG_HOST\n // overrides the schema host\" exercises the case where config has a\n // non-empty default (which would otherwise short-circuit before\n // resolvePostHogHost() is reached).\n const host = process.env.TITAN_POSTHOG_HOST\n || (tel.posthogHost as string)\n || resolvePostHogHost();\n return { apiKey, host };\n}\n\n/** Fire a single event to PostHog's /capture endpoint via fetch. */\nasync function capture(\n apiKey: string,\n host: string,\n distinctId: string,\n event: string,\n properties: Record<string, unknown>,\n timestamp?: string\n): Promise<void> {\n const url = `${host.replace(/\\/$/, '')}/capture/`;\n const body = {\n api_key: apiKey,\n event,\n distinct_id: distinctId,\n properties: {\n ...properties,\n // Mark these as TITAN-generated so PostHog filters work\n $lib: 'titan-analytics',\n $lib_version: (await import('../utils/constants.js')).TITAN_VERSION,\n },\n timestamp: timestamp || new Date().toISOString(),\n };\n\n const res = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body),\n signal: AbortSignal.timeout(8000),\n });\n\n if (!res.ok) {\n const text = await res.text().catch(() => '');\n throw new Error(`PostHog HTTP ${res.status}: ${text.slice(0, 200)}`);\n }\n}\n\n/** Send a $identify call with person properties (hardware profile). */\nasync function identify(\n apiKey: string,\n host: string,\n distinctId: string,\n properties: Record<string, unknown>,\n timestamp?: string\n): Promise<void> {\n await capture(apiKey, host, distinctId, '$identify', {\n $set: properties,\n }, timestamp);\n}\n\nexport interface PostHogSendStatus {\n enabled: boolean;\n sentCount: number;\n failedCount: number;\n lastError?: string;\n lastSuccessAt?: string;\n}\n\nconst status: PostHogSendStatus = { enabled: false, sentCount: 0, failedCount: 0 };\n\nexport function getPostHogStatus(): PostHogSendStatus {\n return { ...status };\n}\n\n/**\n * Map a TITAN analytics payload to PostHog event(s) and send.\n * This is best-effort: failures are logged but not thrown.\n */\nexport async function sendPostHogEvent(payload: Record<string, unknown>): Promise<void> {\n const cfg = getPostHogConfig();\n if (!cfg) {\n status.enabled = false;\n return;\n }\n\n status.enabled = true;\n const { apiKey, host } = cfg;\n const distinctId = typeof payload.installId === 'string' ? payload.installId : 'unknown';\n const type = typeof payload.type === 'string' ? payload.type : 'unknown';\n const timestamp = typeof payload.timestamp === 'string'\n ? payload.timestamp\n : typeof payload.collectedAt === 'string'\n ? payload.collectedAt\n : undefined;\n\n const ts = timestamp || new Date().toISOString();\n try {\n if (type === 'system_profile') {\n // Hardware specs become person properties so we can segment by GPU, OS, etc.\n const personProps: Record<string, unknown> = {};\n const allowlist = [\n 'os', 'osRelease', 'arch', 'cpuCores',\n 'ramTotalGB', 'gpuVendor', 'gpuVramGB',\n 'installMethod', 'diskTotalGB', 'version', 'nodeVersion',\n ];\n for (const key of allowlist) {\n if (key in payload) personProps[key] = payload[key];\n }\n await identify(apiKey, host, distinctId, personProps, ts);\n\n // Also fire a lightweight system_profile event for funnels\n await capture(apiKey, host, distinctId, 'system_profile', {\n version: payload.version,\n installMethod: payload.installMethod,\n }, ts);\n } else if (type === 'heartbeat') {\n const props: Record<string, unknown> = {\n uptime_seconds: payload.uptimeSeconds,\n active_sessions: payload.activeSessions,\n version: payload.version,\n };\n if (payload.features) {\n const f = payload.features as Record<string, unknown>;\n for (const [k, v] of Object.entries(f)) {\n props[`feature_${k}`] = v;\n }\n }\n await capture(apiKey, host, distinctId, 'heartbeat', props, ts);\n } else if (type === 'install' || type === 'update') {\n await capture(apiKey, host, distinctId, type, {\n version: payload.version,\n from_version: payload.fromVersion,\n install_method: payload.installMethod,\n }, ts);\n } else if (type === 'error') {\n await capture(apiKey, host, distinctId, 'error', {\n error_type: payload.errorType,\n message: payload.message,\n version: payload.version,\n }, ts);\n } else {\n // Passthrough for any future event types\n const { type: _type, installId: _installId, ...rest } = payload;\n await capture(apiKey, host, distinctId, type, rest, ts);\n }\n\n status.sentCount += 1;\n status.lastSuccessAt = new Date().toISOString();\n status.lastError = undefined;\n } catch (err) {\n status.failedCount += 1;\n status.lastError = (err as Error).message || String(err);\n logger.debug(COMPONENT, `PostHog send failed: ${status.lastError}`);\n }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/analytics/posthog.ts"],"sourcesContent":["/**\n * TITAN — PostHog Analytics Bridge\n *\n * v6.1.0 Phase B (`6.1.0-beta.2`):\n * - Migrated from hand-rolled `fetch()` to the official `posthog-node` SDK.\n * - SDK is **lazy-imported** — `posthog-node` never enters memory until\n * telemetry is enabled AND the user has consented to the current\n * `REQUIRED_CONSENT_VERSION`. This is the GDPR \"no pre-consent network\n * calls\" guarantee.\n * - CLI-tuned init: `flushAt: 1`, `flushInterval: 0`, `disableGeoip: true`\n * so events flush immediately on short-lived CLI invocations.\n * - Graceful shutdown on `SIGTERM`, `SIGINT`, `beforeExit`.\n * - `$process_person_profile: false` on every event by default — anonymous\n * capture, no person profile is created. Identified capture (with\n * `$set` / `$set_once`) will land in a later phase once `titan login`\n * ships.\n * - Preserves Phase A: env-var override (`TITAN_POSTHOG_KEY`,\n * `TITAN_POSTHOG_HOST`), EU/US timezone-based resolver, secret scrubber\n * on outbound payloads.\n * - Honors `TITAN_TELEMETRY_DISABLED=1` (or `'true'`) — runtime kill switch\n * that overrides any config state and short-circuits before any\n * `posthog-node` import.\n */\nimport { loadConfig } from '../config/config.js';\nimport logger from '../utils/logger.js';\nimport { redactSecrets } from '../security/secretGuard.js';\nimport { REQUIRED_CONSENT_VERSION } from './consent.js';\n\nconst COMPONENT = 'PostHog';\n\n/**\n * v6.1.0-beta.1 — env-var override + EU/US auto-detection.\n *\n * Precedence (highest first):\n * 1. `process.env.TITAN_POSTHOG_KEY` → overrides project key\n * 2. `process.env.TITAN_POSTHOG_HOST` → overrides ingest host\n * 3. `telemetry.posthogApiKey` / `telemetry.posthogHost` from config\n * 4. EU/US auto-detection via IANA timezone (NO network / NO IP lookup —\n * privacy-safe + offline-safe)\n * 5. US default\n *\n * EU detection (GDPR data-residency):\n * `Intl.DateTimeFormat().resolvedOptions().timeZone` is consulted; if it\n * starts with `Europe/`, or matches one of the EU-adjacent Atlantic /\n * Africa zones (Azores, Canary, Faroe, Madeira, Reykjavik, Ceuta) the\n * host resolves to `https://eu.i.posthog.com` (PostHog Cloud Frankfurt).\n * Everything else routes to the US.\n */\nconst EU_TIMEZONE_REGEX = /^Europe\\//;\nconst EU_ADJACENT_ZONES = new Set([\n 'Atlantic/Azores',\n 'Atlantic/Canary',\n 'Atlantic/Faroe',\n 'Atlantic/Madeira',\n 'Atlantic/Reykjavik',\n 'Africa/Ceuta',\n]);\n\nexport function resolvePostHogHost(now?: { timeZone?: string }): string {\n if (process.env.TITAN_POSTHOG_HOST) return process.env.TITAN_POSTHOG_HOST;\n const tz = (() => {\n if (now?.timeZone) return now.timeZone;\n try { return Intl.DateTimeFormat().resolvedOptions().timeZone; }\n catch { return ''; }\n })();\n if (EU_TIMEZONE_REGEX.test(tz) || EU_ADJACENT_ZONES.has(tz)) {\n return 'https://eu.i.posthog.com';\n }\n return 'https://us.i.posthog.com';\n}\n\n/**\n * Runtime kill switch. Honored before any consent / config check so an\n * operator can hard-disable telemetry without touching config files (CI,\n * sandboxes, ephemeral container runs, paranoid users).\n */\nfunction isHardDisabledByEnv(): boolean {\n const v = process.env.TITAN_TELEMETRY_DISABLED;\n return v === '1' || v === 'true';\n}\n\n/**\n * Read consent + config. Returns `undefined` if telemetry isn't supposed\n * to fire (disabled, no key, no consent for current version, hard env\n * kill). Caller MUST treat undefined as \"do nothing\" — no network, no\n * `posthog-node` import.\n */\nfunction getPostHogConfig(): { apiKey: string; host: string } | undefined {\n if (isHardDisabledByEnv()) return undefined;\n const cfg = loadConfig();\n const tel = cfg.telemetry as Record<string, unknown> | undefined;\n if (!tel?.enabled) return undefined;\n // v6.1.0 Phase B — re-consent gate. If the user hasn't agreed to the\n // current payload generation, treat as opted-out until the CLI\n // re-prompts and bumps consentVersion.\n const consentVersion = typeof tel.consentVersion === 'number' ? tel.consentVersion : 0;\n if (consentVersion < REQUIRED_CONSENT_VERSION) return undefined;\n const apiKey = process.env.TITAN_POSTHOG_KEY || (tel.posthogApiKey as string) || undefined;\n if (!apiKey) return undefined;\n const host = process.env.TITAN_POSTHOG_HOST\n || (tel.posthogHost as string)\n || resolvePostHogHost();\n return { apiKey, host };\n}\n\n// ── Lazy SDK client ────────────────────────────────────────────────\n//\n// `posthog-node` must NOT be imported at module load. It's only pulled in\n// the first time a send is attempted AND only after consent + enabled\n// checks pass. Once instantiated the client is reused for the lifetime\n// of the process.\n\n// Use a structural type so we don't import `PostHog` at module load. The\n// real type comes in via dynamic `await import('posthog-node')`.\ninterface MinimalPostHogClient {\n capture(props: {\n distinctId: string;\n event: string;\n properties?: Record<string, unknown>;\n timestamp?: Date;\n }): unknown;\n shutdown(): Promise<void> | unknown;\n flush?(): Promise<void> | unknown;\n}\n\nlet _client: MinimalPostHogClient | null = null;\nlet _shutdownHooksInstalled = false;\n\n/**\n * Install graceful-shutdown hooks once per process so queued events\n * aren't dropped on `SIGTERM` / `SIGINT` / `beforeExit`. PostHog docs\n * explicitly require `await posthog.shutdown()` before process exit.\n */\nfunction installShutdownHooks(): void {\n if (_shutdownHooksInstalled) return;\n _shutdownHooksInstalled = true;\n const shutdown = async () => {\n if (!_client) return;\n try {\n await _client.shutdown();\n } catch {\n /* never let telemetry shutdown itself crash exit */\n }\n };\n process.on('SIGTERM', shutdown);\n process.on('SIGINT', shutdown);\n process.on('beforeExit', shutdown);\n}\n\n/**\n * Lazy SDK accessor. Returns `null` if telemetry is off or unconfigured.\n * The `posthog-node` module is only `import()`-ed inside the success\n * branch, so a user who never opts in has the module out of their\n * `require.cache` entirely.\n */\nasync function getClient(): Promise<{\n client: MinimalPostHogClient;\n apiKey: string;\n host: string;\n} | null> {\n const cfg = getPostHogConfig();\n if (!cfg) return null;\n if (_client) return { client: _client, apiKey: cfg.apiKey, host: cfg.host };\n\n // Lazy import — first place `posthog-node` enters memory.\n const mod = await import('posthog-node');\n const PostHog = (mod as unknown as { PostHog: new (key: string, opts: Record<string, unknown>) => MinimalPostHogClient }).PostHog;\n _client = new PostHog(cfg.apiKey, {\n host: cfg.host,\n // CLI-tuned: TITAN commands are usually short-lived. The SDK\n // defaults (`flushAt: 20`, `flushInterval: 10000`) would delay\n // events past process exit.\n flushAt: 1,\n flushInterval: 0,\n // Explicitly pin geoip off — the SDK default is true but we want\n // a hard guarantee that no IP-based geolocation is happening on\n // our anonymous events. EU/US routing is already handled via\n // `resolvePostHogHost()` from the local IANA timezone.\n disableGeoip: true,\n });\n installShutdownHooks();\n return { client: _client, apiKey: cfg.apiKey, host: cfg.host };\n}\n\n// ── Status surface ─────────────────────────────────────────────────\n\nexport interface PostHogSendStatus {\n enabled: boolean;\n sentCount: number;\n failedCount: number;\n lastError?: string;\n lastSuccessAt?: string;\n}\n\nconst status: PostHogSendStatus = { enabled: false, sentCount: 0, failedCount: 0 };\n\nexport function getPostHogStatus(): PostHogSendStatus {\n return { ...status };\n}\n\n// ── Payload scrubbing ──────────────────────────────────────────────\n//\n// Defense-in-depth: callers (bugReports.ts, featureTracker.ts) already\n// scrub before handing payloads here, but anything string-shaped that\n// slips through gets caught here too. The local jsonl keeps the\n// un-scrubbed copy for operator review.\n\nfunction scrubProps(props: Record<string, unknown>): Record<string, unknown> {\n const out: Record<string, unknown> = {};\n for (const [k, v] of Object.entries(props)) {\n if (typeof v === 'string') {\n out[k] = redactSecrets(v);\n } else if (Array.isArray(v)) {\n out[k] = v.map(item => (typeof item === 'string' ? redactSecrets(item) : item));\n } else {\n out[k] = v;\n }\n }\n return out;\n}\n\n/**\n * Map a TITAN analytics payload to PostHog event(s) and send.\n * Best-effort: failures are logged but never thrown.\n *\n * Anonymous capture default — every event sets `$process_person_profile:\n * false` so PostHog does NOT create a person profile. Events remain\n * queryable by `distinct_id` (the anonymous install UUID) for funnels /\n * retention, but no `$set` person properties accumulate. The hardware\n * fingerprint that used to flow through `$identify` is now sent as\n * regular event properties on a `system_profile` event.\n */\nexport async function sendPostHogEvent(payload: Record<string, unknown>): Promise<void> {\n const handle = await getClient();\n if (!handle) {\n status.enabled = false;\n return;\n }\n\n status.enabled = true;\n const { client } = handle;\n const distinctId = typeof payload.installId === 'string' ? payload.installId : 'unknown';\n const type = typeof payload.type === 'string' ? payload.type : 'unknown';\n const timestamp = typeof payload.timestamp === 'string'\n ? payload.timestamp\n : typeof payload.collectedAt === 'string'\n ? payload.collectedAt\n : undefined;\n\n const tsDate = timestamp ? new Date(timestamp) : new Date();\n\n try {\n const { TITAN_VERSION } = await import('../utils/constants.js');\n // Common properties stamped on every event so downstream PostHog\n // filters can pivot by library + version without re-checking\n // each event type.\n const baseProps: Record<string, unknown> = {\n // Anonymous-by-default — no person profile created.\n // Switch to identified capture (with `$set` / `$set_once`)\n // only after an explicit `titan login` flow.\n $process_person_profile: false,\n $lib: 'titan-analytics',\n $lib_version: TITAN_VERSION,\n };\n\n const send = (event: string, props: Record<string, unknown>) => {\n client.capture({\n distinctId,\n event,\n properties: scrubProps({ ...baseProps, ...props }),\n timestamp: tsDate,\n });\n };\n\n if (type === 'system_profile') {\n const allowlist = [\n 'os', 'osRelease', 'arch', 'cpuCores',\n 'ramTotalGB', 'gpuVendor', 'gpuVramGB',\n 'installMethod', 'diskTotalGB', 'version', 'nodeVersion',\n ];\n const props: Record<string, unknown> = {};\n for (const key of allowlist) {\n if (key in payload) props[key] = payload[key];\n }\n send('system_profile', props);\n } else if (type === 'heartbeat') {\n const props: Record<string, unknown> = {\n uptime_seconds: payload.uptimeSeconds,\n active_sessions: payload.activeSessions,\n version: payload.version,\n };\n if (payload.features) {\n const f = payload.features as Record<string, unknown>;\n for (const [k, v] of Object.entries(f)) {\n props[`feature_${k}`] = v;\n }\n }\n send('heartbeat', props);\n } else if (type === 'install' || type === 'update') {\n send(type, {\n version: payload.version,\n from_version: payload.fromVersion,\n install_method: payload.installMethod,\n });\n } else if (type === 'error') {\n send('error', {\n error_type: payload.errorType,\n message: payload.message,\n version: payload.version,\n });\n } else {\n // Passthrough for any future event types\n const { type: _type, installId: _installId, ...rest } = payload;\n send(type, rest);\n }\n\n // flushAt=1 means each capture flushes immediately, but call\n // flush() defensively in case the user is on a gateway runtime\n // that overrode the CLI defaults.\n try { await client.flush?.(); } catch { /* ignore */ }\n\n status.sentCount += 1;\n status.lastSuccessAt = new Date().toISOString();\n status.lastError = undefined;\n } catch (err) {\n status.failedCount += 1;\n status.lastError = (err as Error).message || String(err);\n logger.debug(COMPONENT, `PostHog send failed: ${status.lastError}`);\n }\n}\n\n/**\n * Test-only: reset the lazy client + shutdown-hook installation flag.\n * Real CLI flow keeps the client alive for the process lifetime. Used by\n * `tests/v610-posthog-lazy-require.test.ts` to verify the module isn't\n * imported until consent + enabled gates pass.\n */\nexport function _resetPostHogClientForTests(): void {\n _client = null;\n _shutdownHooksInstalled = false;\n}\n"],"mappings":"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","names":[]}
|
package/dist/cli/index.js
CHANGED
|
@@ -25,6 +25,9 @@ import { searchSkills, installSkill, installFromUrl } from "../skills/marketplac
|
|
|
25
25
|
import { scaffoldSkill, testSkill } from "../skills/scaffold.js";
|
|
26
26
|
import { createTeam, listTeams, getTeam, deleteTeam, addMember, removeMember, createInvite, acceptInvite, getTeamStats, updateMemberRole } from "../security/teams.js";
|
|
27
27
|
import { checkForUpdates } from "../utils/updater.js";
|
|
28
|
+
import { reconsentIfNeeded, runReconsent } from "./reconsent.js";
|
|
29
|
+
import { REQUIRED_CONSENT_VERSION } from "../analytics/consent.js";
|
|
30
|
+
import { resolvePostHogHost } from "../analytics/posthog.js";
|
|
28
31
|
const program = new Command();
|
|
29
32
|
program.name("titan").description(`${TITAN_FULL_NAME} \u2014 Your autonomous AI assistant`).version(TITAN_VERSION);
|
|
30
33
|
program.command("onboard").description("Run the interactive setup wizard").option("--install-daemon", "Install as a system daemon (systemd/launchd)").action(async (options) => {
|
|
@@ -1176,6 +1179,54 @@ program.command("vault").description("Manage encrypted secrets vault").option("-
|
|
|
1176
1179
|
}
|
|
1177
1180
|
process.exit(0);
|
|
1178
1181
|
});
|
|
1182
|
+
const telemetry = program.command("telemetry").description("Manage TITAN anonymous usage telemetry");
|
|
1183
|
+
telemetry.command("status").description("Show current telemetry state (enabled flag, host, consent version, install ID)").action(async () => {
|
|
1184
|
+
const config = loadConfig();
|
|
1185
|
+
const tel = config.telemetry || {};
|
|
1186
|
+
const enabled = tel.enabled === true;
|
|
1187
|
+
const consentVersion = typeof tel.consentVersion === "number" ? tel.consentVersion : 0;
|
|
1188
|
+
const envKill = process.env.TITAN_TELEMETRY_DISABLED === "1" || process.env.TITAN_TELEMETRY_DISABLED === "true";
|
|
1189
|
+
const effectiveEnabled = enabled && !envKill && consentVersion >= REQUIRED_CONSENT_VERSION;
|
|
1190
|
+
const host = process.env.TITAN_POSTHOG_HOST || tel.posthogHost || resolvePostHogHost();
|
|
1191
|
+
let installId = "unknown";
|
|
1192
|
+
try {
|
|
1193
|
+
const { getOrCreateNodeId } = await import("../mesh/identity.js");
|
|
1194
|
+
installId = getOrCreateNodeId();
|
|
1195
|
+
} catch {
|
|
1196
|
+
}
|
|
1197
|
+
console.log(chalk.cyan("\nTITAN Telemetry Status\n"));
|
|
1198
|
+
console.log(` enabled (config): ${enabled ? chalk.green("true") : chalk.gray("false")}`);
|
|
1199
|
+
console.log(` effective: ${effectiveEnabled ? chalk.green("ON") : chalk.gray("OFF")}`);
|
|
1200
|
+
console.log(` consent version: ${consentVersion} / ${REQUIRED_CONSENT_VERSION} required`);
|
|
1201
|
+
console.log(` host: ${host}`);
|
|
1202
|
+
console.log(` install id: ${installId}`);
|
|
1203
|
+
if (envKill) {
|
|
1204
|
+
console.log(chalk.yellow("\n TITAN_TELEMETRY_DISABLED is set \u2014 telemetry is hard-disabled regardless of config."));
|
|
1205
|
+
}
|
|
1206
|
+
if (enabled && consentVersion < REQUIRED_CONSENT_VERSION) {
|
|
1207
|
+
console.log(chalk.yellow(`
|
|
1208
|
+
Re-consent required for v6 \u2014 run: ${chalk.cyan("titan telemetry enable")}`));
|
|
1209
|
+
}
|
|
1210
|
+
console.log("");
|
|
1211
|
+
process.exit(0);
|
|
1212
|
+
});
|
|
1213
|
+
telemetry.command("enable").description("Opt in to anonymous telemetry (runs the v6 consent prompt)").action(async () => {
|
|
1214
|
+
await runReconsent();
|
|
1215
|
+
process.exit(0);
|
|
1216
|
+
});
|
|
1217
|
+
telemetry.command("disable").description("Opt out of anonymous telemetry").action(() => {
|
|
1218
|
+
const config = loadConfig();
|
|
1219
|
+
const nextTel = {
|
|
1220
|
+
...config.telemetry,
|
|
1221
|
+
enabled: false,
|
|
1222
|
+
// Stamp current version so we don't re-prompt them after they
|
|
1223
|
+
// explicitly disabled — that would be obnoxious.
|
|
1224
|
+
consentVersion: REQUIRED_CONSENT_VERSION
|
|
1225
|
+
};
|
|
1226
|
+
updateConfig({ telemetry: nextTel });
|
|
1227
|
+
console.log(chalk.green("Telemetry disabled. No data leaves your machine."));
|
|
1228
|
+
process.exit(0);
|
|
1229
|
+
});
|
|
1179
1230
|
program.command("autopilot").description("Manage TITAN autopilot \u2014 hands-free scheduled agent runs").option("--init", "Create default AUTOPILOT.md checklist").option("--run", "Trigger an autopilot run immediately").option("--status", "Show autopilot schedule, last run, next run").option("--history", "Show recent autopilot run history").option("--limit <n>", "Number of history entries to show", "10").option("--enable", "Enable autopilot in config").option("--disable", "Disable autopilot in config").action(async (options) => {
|
|
1180
1231
|
if (options.init) {
|
|
1181
1232
|
const { initChecklist } = await import("../agent/autopilot.js");
|
|
@@ -1255,6 +1306,13 @@ Autopilot History (last ${runs.length} runs)
|
|
|
1255
1306
|
(async () => {
|
|
1256
1307
|
initFileLogger(TITAN_LOGS_DIR);
|
|
1257
1308
|
await checkForUpdates();
|
|
1309
|
+
const skipReconsent = process.env.TITAN_TELEMETRY_DISABLED === "1" || process.env.TITAN_TELEMETRY_DISABLED === "true" || process.argv[2] === "telemetry";
|
|
1310
|
+
if (!skipReconsent) {
|
|
1311
|
+
try {
|
|
1312
|
+
await reconsentIfNeeded();
|
|
1313
|
+
} catch {
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1258
1316
|
await program.parseAsync();
|
|
1259
1317
|
})().catch((err) => {
|
|
1260
1318
|
console.error(err);
|