infernoflow 0.35.5 → 0.35.6

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.
@@ -3,19 +3,33 @@
3
3
  *
4
4
  * Collects in-CLI feedback about infernoflow and optionally opens the web form.
5
5
  *
6
+ * Responses are:
7
+ * 1. Saved locally in ~/.infernoflow/feedback.json
8
+ * 2. POSTed to Formspree (free, no backend needed — Ron gets email per submission)
9
+ *
10
+ * To activate Formspree:
11
+ * 1. Go to https://formspree.io → create free account → New Form
12
+ * 2. Replace FORMSPREE_ENDPOINT below with your form URL (e.g. https://formspree.io/f/xabc1234)
13
+ * 3. Publish the package — submissions arrive in your email immediately
14
+ *
6
15
  * Usage:
7
16
  * infernoflow feedback Interactive 5-question survey
8
17
  * infernoflow feedback --form Open Google Form in browser
9
18
  * infernoflow feedback --json Print last stored feedback as JSON
10
19
  */
11
20
 
12
- import * as fs from "node:fs";
13
- import * as path from "node:path";
14
- import * as os from "node:os";
21
+ import * as fs from "node:fs";
22
+ import * as path from "node:path";
23
+ import * as os from "node:os";
24
+ import * as https from "node:https";
15
25
  import * as readline from "node:readline";
16
26
  import { execSync } from "node:child_process";
17
27
  import { bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
18
28
 
29
+ // ── Replace this URL with your Formspree form endpoint ───────────────────────
30
+ // Get one free at https://formspree.io (50 submissions/mo free, emails you each response)
31
+ const FORMSPREE_ENDPOINT = "https://formspree.io/f/infernoflow"; // placeholder
32
+
19
33
  const FEEDBACK_FORM_URL = "https://forms.gle/infernoflow-feedback"; // placeholder — replace with real form
20
34
  const FEEDBACK_FILE = path.join(os.homedir(), ".infernoflow", "feedback.json");
21
35
 
@@ -48,6 +62,39 @@ const QUESTIONS = [
48
62
  },
49
63
  ];
50
64
 
65
+ /**
66
+ * Fire-and-forget POST to Formspree.
67
+ * Formspree emails the form owner on each submission — zero backend needed.
68
+ */
69
+ function sendToFormspree(record) {
70
+ try {
71
+ if (!FORMSPREE_ENDPOINT || FORMSPREE_ENDPOINT.includes("placeholder")) return;
72
+
73
+ const url = new URL(FORMSPREE_ENDPOINT);
74
+ const body = JSON.stringify({
75
+ ...record.responses,
76
+ _subject: `infernoflow feedback v${record.version}`,
77
+ _version: record.version,
78
+ _ts: record.ts,
79
+ });
80
+
81
+ const req = https.request({
82
+ hostname: url.hostname,
83
+ path: url.pathname,
84
+ method: "POST",
85
+ headers: {
86
+ "Content-Type": "application/json",
87
+ "Accept": "application/json",
88
+ "Content-Length": Buffer.byteLength(body),
89
+ },
90
+ timeout: 5000,
91
+ });
92
+ req.on("error", () => {}); // never surface errors to the user
93
+ req.write(body);
94
+ req.end();
95
+ } catch {}
96
+ }
97
+
51
98
  function saveFeedback(responses) {
52
99
  const dir = path.dirname(FEEDBACK_FILE);
53
100
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
@@ -120,6 +167,9 @@ async function runSurvey() {
120
167
 
121
168
  const record = saveFeedback(responses);
122
169
 
170
+ // Fire-and-forget cloud send (Formspree — Ron gets an email)
171
+ sendToFormspree(record);
172
+
123
173
  console.log(green(" ✔ Feedback saved — thank you!\n"));
124
174
  console.log(gray(" Stored in: ~/.infernoflow/feedback.json"));
125
175
  console.log(gray(` Version: ${record.version}`));
@@ -3,35 +3,43 @@
3
3
  *
4
4
  * Opt-in, fire-and-forget usage analytics.
5
5
  *
6
- * - Stored in ~/.infernoflow/telemetry.json
7
- * - Never enabled without explicit consent
8
- * - Never blocks the CLI all sends are async / best-effort
9
- * - Never sends code, file contents, capability names, or personal data
10
- * Only sends: command name, infernoflow version, Node version, OS platform
6
+ * What we collect (nothing else):
7
+ * command name (e.g. "log", "switch")
8
+ * anonymous install UUID (random, never linked to identity)
9
+ * infernoflow version
10
+ * Node version + OS platform
11
+ * ✅ timezone (geography estimation only)
12
+ * ✅ project type (frontend/backend/unknown — inferred from package.json)
13
+ * ✅ IDE (from env variables)
14
+ * ❌ no code, no file names, no personal data, no IP stored
11
15
  *
12
- * Consent is requested lazily on the first interactive infernoflow run
13
- * after install (if no consent decision is stored).
16
+ * Backend: PostHog free tier (50K events/mo free, EU-hosted available)
17
+ * Config: ~/.infernoflow/telemetry.json
18
+ * Events: ~/.infernoflow/events.jsonl (local mirror)
19
+ *
20
+ * Consent is requested lazily after 3 interactive runs.
14
21
  */
15
22
 
16
- import * as fs from "node:fs";
17
- import * as path from "node:path";
18
- import * as os from "node:os";
23
+ import * as fs from "node:fs";
24
+ import * as path from "node:path";
25
+ import * as os from "node:os";
19
26
  import * as https from "node:https";
27
+ import * as crypto from "node:crypto";
20
28
 
21
- const CONFIG_DIR = path.join(os.homedir(), ".infernoflow");
29
+ const CONFIG_DIR = path.join(os.homedir(), ".infernoflow");
22
30
  const TELEMETRY_FILE = path.join(CONFIG_DIR, "telemetry.json");
23
31
  const EVENTS_FILE = path.join(CONFIG_DIR, "events.jsonl");
24
32
 
25
- const ENDPOINT = "https://telemetry.infernoflow.dev/v1/event"; // placeholder
33
+ // PostHog free tier swap to your own project API key when ready
34
+ // https://posthog.com → create free project → Settings → Project API Key
35
+ const POSTHOG_HOST = "https://app.posthog.com";
36
+ const POSTHOG_KEY = "phc_infernoflow_placeholder"; // replace with real key from PostHog dashboard
26
37
 
27
38
  // ── Config helpers ────────────────────────────────────────────────────────────
28
39
 
29
40
  function readConfig() {
30
- try {
31
- return JSON.parse(fs.readFileSync(TELEMETRY_FILE, "utf8"));
32
- } catch {
33
- return null;
34
- }
41
+ try { return JSON.parse(fs.readFileSync(TELEMETRY_FILE, "utf8")); }
42
+ catch { return null; }
35
43
  }
36
44
 
37
45
  function writeConfig(data) {
@@ -39,105 +47,171 @@ function writeConfig(data) {
39
47
  fs.writeFileSync(TELEMETRY_FILE, JSON.stringify(data, null, 2), "utf8");
40
48
  }
41
49
 
42
- /** Returns true if the user has opted in */
50
+ /** Generate a random anonymous install UUID stored once, never changes */
51
+ function generateInstallId() {
52
+ // Use crypto.randomUUID if available (Node 15.6+), else fallback
53
+ try { return crypto.randomUUID(); }
54
+ catch { return "ifl_" + crypto.randomBytes(16).toString("hex"); }
55
+ }
56
+
57
+ /** Get or create the persistent anonymous install ID */
58
+ function getOrCreateInstallId() {
59
+ const cfg = readConfig() || {};
60
+ if (cfg.installId) return cfg.installId;
61
+ const installId = generateInstallId();
62
+ writeConfig({ ...cfg, installId });
63
+ return installId;
64
+ }
65
+
43
66
  export function isTelemetryEnabled() {
44
- const cfg = readConfig();
45
- return cfg?.enabled === true;
67
+ return readConfig()?.enabled === true;
46
68
  }
47
69
 
48
- /** Returns true if the user has made a consent decision (either way) */
49
70
  export function hasConsentDecision() {
50
71
  const cfg = readConfig();
51
72
  return cfg !== null && typeof cfg.enabled === "boolean";
52
73
  }
53
74
 
75
+ // ── Context detection ─────────────────────────────────────────────────────────
76
+
77
+ function detectIde() {
78
+ if (process.env.CURSOR_SESSION) return "cursor";
79
+ if (process.env.COPILOT_SESSION) return "copilot";
80
+ if (process.env.CLAUDE_CODE_SESSION) return "claude-code";
81
+ if (process.env.WINDSURF_SESSION) return "windsurf";
82
+ if (process.env.TERM_PROGRAM === "vscode") return "vscode";
83
+ return "unknown";
84
+ }
85
+
86
+ function detectProjectType() {
87
+ try {
88
+ const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
89
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
90
+ if (deps["next"] || deps["nuxt"] || deps["remix"]) return "fullstack";
91
+ if (deps["react"] || deps["vue"] || deps["svelte"]) return "frontend";
92
+ if (deps["express"] || deps["fastify"] || deps["koa"]) return "backend";
93
+ if (deps["@angular/core"]) return "frontend";
94
+ return "js";
95
+ } catch {
96
+ return "unknown";
97
+ }
98
+ }
99
+
100
+ function getTimezone() {
101
+ try { return Intl.DateTimeFormat().resolvedOptions().timeZone; }
102
+ catch { return "unknown"; }
103
+ }
104
+
105
+ function getVersion() {
106
+ try {
107
+ const pkgPath = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../package.json");
108
+ return JSON.parse(fs.readFileSync(pkgPath, "utf8")).version;
109
+ } catch { return "unknown"; }
110
+ }
111
+
54
112
  // ── Consent prompt ────────────────────────────────────────────────────────────
55
113
 
56
- /**
57
- * Silently skip if consent already given, or if running non-interactively.
58
- * Call this once at the start of each interactive CLI run.
59
- */
60
114
  export async function ensureTelemetryConsent() {
61
115
  if (hasConsentDecision()) return;
62
116
  if (!process.stdin.isTTY) return;
63
117
 
64
- // Only ask after 3+ runs (let the user experience it first)
65
- const cfg = readConfig() || {};
118
+ // Only ask after 3+ interactive runs let the user experience it first
119
+ const cfg = readConfig() || {};
66
120
  const runs = (cfg.runs || 0) + 1;
67
- writeConfig({ ...cfg, runs, enabled: false }); // default off until explicit consent
121
+ writeConfig({ ...cfg, runs, enabled: false });
68
122
 
69
123
  if (runs < 3) return;
70
124
 
71
- // Show one-time prompt
72
125
  const { createInterface } = await import("node:readline");
73
126
  const rl = createInterface({ input: process.stdin, output: process.stdout });
74
127
 
75
128
  const answer = await new Promise(resolve => {
76
129
  process.stdout.write(
77
130
  "\n 📡 Help improve infernoflow?\n" +
78
- " Share anonymous usage data (command names only no code, no content).\n" +
79
- " Type 'y' to opt in, any other key to decline. You can change this later with: infernoflow telemetry on/off\n" +
131
+ " Share anonymous usage data command names, OS, timezone. No code. No personal data.\n" +
132
+ " Type 'y' to opt in, anything else to decline. (infernoflow telemetry off to change later)\n" +
80
133
  " → "
81
134
  );
82
135
  rl.question("", resolve);
83
136
  });
84
137
  rl.close();
85
138
 
86
- const enabled = answer.trim().toLowerCase() === "y";
87
- writeConfig({ enabled, runs, decidedAt: new Date().toISOString() });
139
+ const enabled = answer.trim().toLowerCase() === "y";
140
+ const installId = enabled ? generateInstallId() : null;
141
+ writeConfig({ enabled, installId, runs, decidedAt: new Date().toISOString() });
88
142
 
89
- if (enabled) {
90
- process.stdout.write(" ✔ Telemetry enabled — thank you! (infernoflow telemetry off to disable)\n\n");
91
- } else {
92
- process.stdout.write(" ✔ Got it — telemetry off. (infernoflow telemetry on to enable later)\n\n");
93
- }
143
+ process.stdout.write(
144
+ enabled
145
+ ? " Telemetry enabled — thank you! (infernoflow telemetry off to disable)\n\n"
146
+ : " ✔ No problem — telemetry off. (infernoflow telemetry on to enable later)\n\n"
147
+ );
94
148
  }
95
149
 
96
150
  // ── Event tracking ────────────────────────────────────────────────────────────
97
151
 
98
- /** Get current package version */
99
- function getVersion() {
100
- try {
101
- const pkgPath = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../package.json");
102
- return JSON.parse(fs.readFileSync(pkgPath, "utf8")).version;
103
- } catch {
104
- return "unknown";
105
- }
106
- }
107
-
108
152
  /**
109
153
  * Track a command invocation. Fire-and-forget — never throws, never blocks.
110
- * @param {string} command The command name (e.g. "log", "switch", "recap")
154
+ * @param {string} command e.g. "log", "switch", "recap"
111
155
  */
112
156
  export function trackEvent(command) {
113
157
  if (!isTelemetryEnabled()) return;
114
158
 
159
+ const installId = getOrCreateInstallId();
160
+
115
161
  const event = {
116
- ts: new Date().toISOString(),
162
+ ts: new Date().toISOString(),
117
163
  command,
118
- version: getVersion(),
119
- node: process.version,
120
- os: process.platform,
164
+ installId, // anonymous UUID — links events from same install
165
+ version: getVersion(),
166
+ node: process.version,
167
+ os: process.platform,
168
+ timezone: getTimezone(), // geography (continent/country) estimation
169
+ ide: detectIde(),
170
+ projectType: detectProjectType(),
121
171
  };
122
172
 
123
- // Always append to local event log first
173
+ // 1. Mirror to local event log
124
174
  try {
125
175
  if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
126
176
  fs.appendFileSync(EVENTS_FILE, JSON.stringify(event) + "\n", "utf8");
127
177
  } catch {}
128
178
 
129
- // Fire-and-forget HTTP POST (best effort — no await)
179
+ // 2. Fire-and-forget to PostHog
180
+ // PostHog expects: { api_key, event, distinct_id, properties, timestamp }
181
+ _postHog(installId, command, event);
182
+ }
183
+
184
+ function _postHog(distinctId, eventName, props) {
130
185
  try {
131
- const body = JSON.stringify(event);
132
- const url = new URL(ENDPOINT);
133
- const req = https.request({
186
+ const body = JSON.stringify({
187
+ api_key: POSTHOG_KEY,
188
+ event: eventName,
189
+ distinct_id: distinctId,
190
+ properties: {
191
+ command: props.command,
192
+ version: props.version,
193
+ node: props.node,
194
+ os: props.os,
195
+ timezone: props.timezone,
196
+ ide: props.ide,
197
+ projectType: props.projectType,
198
+ $lib: "infernoflow-cli",
199
+ },
200
+ timestamp: props.ts,
201
+ });
202
+
203
+ const url = new URL(POSTHOG_HOST + "/capture/");
204
+ const req = https.request({
134
205
  hostname: url.hostname,
135
206
  path: url.pathname,
136
207
  method: "POST",
137
- headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
138
- timeout: 3000,
208
+ headers: {
209
+ "Content-Type": "application/json",
210
+ "Content-Length": Buffer.byteLength(body),
211
+ },
212
+ timeout: 3000,
139
213
  });
140
- req.on("error", () => {}); // silently ignore all errors
214
+ req.on("error", () => {}); // never surface telemetry errors
141
215
  req.write(body);
142
216
  req.end();
143
217
  } catch {}
@@ -150,8 +224,9 @@ export async function telemetryCommand(args) {
150
224
  const sub = args[0];
151
225
 
152
226
  if (sub === "on") {
153
- const cfg = readConfig() || {};
154
- writeConfig({ ...cfg, enabled: true, decidedAt: new Date().toISOString() });
227
+ const cfg = readConfig() || {};
228
+ const installId = cfg.installId || generateInstallId();
229
+ writeConfig({ ...cfg, enabled: true, installId, decidedAt: new Date().toISOString() });
155
230
  console.log(green("\n ✔ Telemetry enabled — thank you for helping improve infernoflow!\n"));
156
231
  return;
157
232
  }
@@ -159,28 +234,30 @@ export async function telemetryCommand(args) {
159
234
  if (sub === "off") {
160
235
  const cfg = readConfig() || {};
161
236
  writeConfig({ ...cfg, enabled: false, decidedAt: new Date().toISOString() });
162
- console.log(green("\n ✔ Telemetry disabled.\n"));
237
+ console.log(green("\n ✔ Telemetry disabled. No data will be sent.\n"));
163
238
  return;
164
239
  }
165
240
 
166
241
  if (sub === "status" || !sub) {
167
- const cfg = readConfig();
168
- const enabled = cfg?.enabled === true;
169
- const decided = cfg?.decidedAt ? new Date(cfg.decidedAt).toLocaleDateString() : "never";
242
+ const cfg = readConfig();
243
+ const enabled = cfg?.enabled === true;
244
+ const decided = cfg?.decidedAt ? new Date(cfg.decidedAt).toLocaleDateString() : "never";
245
+ const installId = cfg?.installId ? cfg.installId.slice(0, 12) + "…" : "none yet";
170
246
 
171
- // Count local events
172
247
  let eventCount = 0;
173
248
  try {
174
- const lines = fs.readFileSync(EVENTS_FILE, "utf8").split("\n").filter(Boolean);
175
- eventCount = lines.length;
249
+ eventCount = fs.readFileSync(EVENTS_FILE, "utf8").split("\n").filter(Boolean).length;
176
250
  } catch {}
177
251
 
178
252
  console.log("\n " + bold("🔥 infernoflow telemetry status") + "\n");
179
- console.log(" Telemetry " + (enabled ? green("enabled") : yellow("disabled")));
253
+ console.log(" Status " + (enabled ? green("enabled") : yellow("disabled")));
254
+ console.log(" Install ID " + gray(installId + " (anonymous, never linked to identity)"));
180
255
  console.log(" Decided " + gray(decided));
181
- console.log(" Events logged " + gray(eventCount + " (local only until enabled)"));
182
- console.log(" Data sent " + gray("command name, infernoflow version, Node version, OS platform"));
183
- console.log(" Data never " + gray("code, file names, capability names, email, personal data"));
256
+ console.log(" Events stored " + gray(eventCount + " locally → " + (enabled ? "also sent to PostHog" : "not sent (disabled)")));
257
+ console.log(" Backend " + gray("PostHog (EU-hosted, no IP stored)"));
258
+ console.log();
259
+ console.log(" " + bold("What we collect:") + " " + gray("command, version, Node, OS, timezone, IDE, project type"));
260
+ console.log(" " + bold("What we never collect:") + " " + gray("code, file names, capability names, email, personal data"));
184
261
  console.log();
185
262
  console.log(gray(" infernoflow telemetry on — enable"));
186
263
  console.log(gray(" infernoflow telemetry off — disable"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infernoflow",
3
- "version": "0.35.5",
3
+ "version": "0.35.6",
4
4
  "description": "Persistent memory for AI coding sessions — captures what agents can't infer from code alone. Works with Copilot, Cursor, Claude, and Windsurf.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,7 +22,11 @@
22
22
  "test:help": "node bin/infernoflow.mjs --help",
23
23
  "build": "node build.mjs",
24
24
  "prepublishOnly": "node build.mjs",
25
- "inferno:promote-draft": "node scripts/inferno-promote-draft.mjs"
25
+ "inferno:promote-draft": "node scripts/inferno-promote-draft.mjs",
26
+ "postinstall": "node -e \"try{require('@scarf/scarf')}catch(e){}\" 2>/dev/null; exit 0"
27
+ },
28
+ "dependencies": {
29
+ "@scarf/scarf": "^1.3.0"
26
30
  },
27
31
  "keywords": [
28
32
  "ai",