speclock 5.5.4 → 5.5.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.
- package/README.md +263 -335
- package/package.json +1 -1
- package/src/cli/index.js +552 -41
- package/src/core/compliance.js +1 -1
- package/src/core/hooks.js +5 -2
- package/src/core/pre-commit-semantic.js +102 -2
- package/src/core/telemetry.js +685 -114
- package/src/dashboard/index.html +2 -2
- package/src/mcp/http-server.js +1 -1
- package/src/mcp/server.js +1 -1
- package/src/templates/rule-packs/fastapi.md +22 -0
- package/src/templates/rule-packs/nextjs.md +22 -0
- package/src/templates/rule-packs/node.md +22 -0
- package/src/templates/rule-packs/python.md +22 -0
- package/src/templates/rule-packs/rails.md +22 -0
- package/src/templates/rule-packs/react.md +22 -0
package/src/core/telemetry.js
CHANGED
|
@@ -1,53 +1,68 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SpecLock Telemetry & Analytics (
|
|
2
|
+
* SpecLock Telemetry & Analytics (v5.5)
|
|
3
3
|
* Opt-in anonymous usage analytics for product improvement.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* NEVER tracks: lock content, project names, file paths, PII.
|
|
7
|
-
* ONLY tracks: tool usage counts, conflict rates, response times, feature adoption.
|
|
5
|
+
* Two coexisting layers:
|
|
8
6
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* 1) Legacy per-project telemetry (v3.5+):
|
|
8
|
+
* Stored in <projectRoot>/.speclock/telemetry.json.
|
|
9
|
+
* Controlled by the SPECLOCK_TELEMETRY env var.
|
|
10
|
+
* Used by trackToolUsage / trackConflict / trackFeature / trackSession
|
|
11
|
+
* and surfaced by getTelemetrySummary.
|
|
12
|
+
*
|
|
13
|
+
* 2) Global opt-in CLI telemetry (investor request):
|
|
14
|
+
* Stored in ~/.speclock/telemetry.jsonl (append-only JSON lines)
|
|
15
|
+
* Config in ~/.speclock/telemetry.json ({ enabled: true|false, decidedAt })
|
|
16
|
+
* Install id in ~/.speclock/install-id (random UUID, generated once)
|
|
17
|
+
* Controlled by `speclock telemetry on|off` or SPECLOCK_TELEMETRY=1.
|
|
18
|
+
* Used by recordCommand() from the CLI entrypoint — fire-and-forget.
|
|
19
|
+
*
|
|
20
|
+
* Privacy:
|
|
21
|
+
* NEVER records: file contents, commit messages, lock content, user names,
|
|
22
|
+
* paths, IP addresses, or any personally identifying information.
|
|
23
|
+
* ONLY records: install-id (random UUID), version, platform, node version,
|
|
24
|
+
* which subcommand was run, exit code, enforcement mode, number of locks,
|
|
25
|
+
* count of rule files, list of MCP clients wired up, and days since install.
|
|
26
|
+
*
|
|
27
|
+
* Resilience:
|
|
28
|
+
* All telemetry operations are wrapped in try/catch and must NEVER block
|
|
29
|
+
* or break the caller. Remote sends use a 1-second timeout and swallow
|
|
30
|
+
* every error.
|
|
11
31
|
*
|
|
12
32
|
* Developed by Sandeep Roy (https://github.com/sgroy10)
|
|
13
33
|
*/
|
|
14
34
|
|
|
15
35
|
import fs from "fs";
|
|
16
36
|
import path from "path";
|
|
37
|
+
import os from "os";
|
|
38
|
+
import crypto from "crypto";
|
|
17
39
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// LEGACY (per-project) TELEMETRY — preserved for backward compatibility.
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
21
43
|
|
|
22
|
-
|
|
44
|
+
const TELEMETRY_FILE = "telemetry.json";
|
|
23
45
|
|
|
24
46
|
let _enabled = null;
|
|
25
|
-
let _buffer = [];
|
|
26
|
-
let _flushTimer = null;
|
|
27
47
|
|
|
28
48
|
/**
|
|
29
|
-
* Check if telemetry is enabled (opt-in only)
|
|
49
|
+
* Check if telemetry is enabled (opt-in only) — legacy env-var path.
|
|
50
|
+
* Returns true if SPECLOCK_TELEMETRY is "true" or "1".
|
|
30
51
|
*/
|
|
31
52
|
export function isTelemetryEnabled() {
|
|
32
53
|
if (_enabled !== null) return _enabled;
|
|
33
|
-
|
|
54
|
+
const v = process.env.SPECLOCK_TELEMETRY;
|
|
55
|
+
_enabled = v === "true" || v === "1";
|
|
34
56
|
return _enabled;
|
|
35
57
|
}
|
|
36
58
|
|
|
37
59
|
/**
|
|
38
|
-
* Reset telemetry state (for
|
|
60
|
+
* Reset telemetry state (primarily for tests).
|
|
39
61
|
*/
|
|
40
62
|
export function resetTelemetry() {
|
|
41
63
|
_enabled = null;
|
|
42
|
-
_buffer = [];
|
|
43
|
-
if (_flushTimer) {
|
|
44
|
-
clearInterval(_flushTimer);
|
|
45
|
-
_flushTimer = null;
|
|
46
|
-
}
|
|
47
64
|
}
|
|
48
65
|
|
|
49
|
-
// --- Local telemetry store ---
|
|
50
|
-
|
|
51
66
|
function telemetryPath(root) {
|
|
52
67
|
return path.join(root, ".speclock", TELEMETRY_FILE);
|
|
53
68
|
}
|
|
@@ -66,13 +81,18 @@ function readTelemetryStore(root) {
|
|
|
66
81
|
|
|
67
82
|
function writeTelemetryStore(root, store) {
|
|
68
83
|
const p = telemetryPath(root);
|
|
69
|
-
|
|
84
|
+
try {
|
|
85
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
86
|
+
fs.writeFileSync(p, JSON.stringify(store, null, 2));
|
|
87
|
+
} catch {
|
|
88
|
+
/* swallow — telemetry must never break callers */
|
|
89
|
+
}
|
|
70
90
|
}
|
|
71
91
|
|
|
72
92
|
function createEmptyStore() {
|
|
73
93
|
return {
|
|
74
94
|
version: "1.0",
|
|
75
|
-
instanceId:
|
|
95
|
+
instanceId: generateLegacyInstanceId(),
|
|
76
96
|
createdAt: new Date().toISOString(),
|
|
77
97
|
updatedAt: new Date().toISOString(),
|
|
78
98
|
toolUsage: {},
|
|
@@ -84,134 +104,129 @@ function createEmptyStore() {
|
|
|
84
104
|
};
|
|
85
105
|
}
|
|
86
106
|
|
|
87
|
-
function
|
|
88
|
-
// Anonymous instance ID — no PII, just random hex
|
|
107
|
+
function generateLegacyInstanceId() {
|
|
89
108
|
const bytes = new Uint8Array(8);
|
|
90
109
|
for (let i = 0; i < 8; i++) bytes[i] = Math.floor(Math.random() * 256);
|
|
91
110
|
return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");
|
|
92
111
|
}
|
|
93
112
|
|
|
94
|
-
// --- Tracking functions ---
|
|
95
|
-
|
|
96
113
|
/**
|
|
97
|
-
* Track a tool invocation
|
|
114
|
+
* Track a tool invocation (legacy).
|
|
98
115
|
*/
|
|
99
116
|
export function trackToolUsage(root, toolName, durationMs) {
|
|
100
117
|
if (!isTelemetryEnabled()) return;
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (!store.toolUsage[toolName]) {
|
|
106
|
-
store.toolUsage[toolName] = { count: 0, totalMs: 0, avgMs: 0 };
|
|
107
|
-
}
|
|
108
|
-
store.toolUsage[toolName].count++;
|
|
109
|
-
store.toolUsage[toolName].totalMs += (durationMs || 0);
|
|
110
|
-
store.toolUsage[toolName].avgMs = Math.round(
|
|
111
|
-
store.toolUsage[toolName].totalMs / store.toolUsage[toolName].count
|
|
112
|
-
);
|
|
113
|
-
|
|
114
|
-
// Response time sampling (keep last 100)
|
|
115
|
-
if (durationMs) {
|
|
116
|
-
store.responseTimes.samples.push(durationMs);
|
|
117
|
-
if (store.responseTimes.samples.length > 100) {
|
|
118
|
-
store.responseTimes.samples = store.responseTimes.samples.slice(-100);
|
|
118
|
+
try {
|
|
119
|
+
const store = readTelemetryStore(root);
|
|
120
|
+
if (!store.toolUsage[toolName]) {
|
|
121
|
+
store.toolUsage[toolName] = { count: 0, totalMs: 0, avgMs: 0 };
|
|
119
122
|
}
|
|
120
|
-
store.
|
|
121
|
-
|
|
123
|
+
store.toolUsage[toolName].count++;
|
|
124
|
+
store.toolUsage[toolName].totalMs += (durationMs || 0);
|
|
125
|
+
store.toolUsage[toolName].avgMs = Math.round(
|
|
126
|
+
store.toolUsage[toolName].totalMs / store.toolUsage[toolName].count
|
|
122
127
|
);
|
|
123
|
-
}
|
|
124
128
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
129
|
+
if (durationMs) {
|
|
130
|
+
store.responseTimes.samples.push(durationMs);
|
|
131
|
+
if (store.responseTimes.samples.length > 100) {
|
|
132
|
+
store.responseTimes.samples = store.responseTimes.samples.slice(-100);
|
|
133
|
+
}
|
|
134
|
+
store.responseTimes.avgMs = Math.round(
|
|
135
|
+
store.responseTimes.samples.reduce((a, b) => a + b, 0) /
|
|
136
|
+
store.responseTimes.samples.length
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
141
|
+
if (!store.daily[today]) store.daily[today] = { calls: 0, conflicts: 0 };
|
|
142
|
+
store.daily[today].calls++;
|
|
137
143
|
|
|
138
|
-
|
|
139
|
-
|
|
144
|
+
const cutoff = new Date();
|
|
145
|
+
cutoff.setDate(cutoff.getDate() - 30);
|
|
146
|
+
const cutoffStr = cutoff.toISOString().slice(0, 10);
|
|
147
|
+
for (const key of Object.keys(store.daily)) {
|
|
148
|
+
if (key < cutoffStr) delete store.daily[key];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
store.updatedAt = new Date().toISOString();
|
|
152
|
+
writeTelemetryStore(root, store);
|
|
153
|
+
} catch { /* swallow */ }
|
|
140
154
|
}
|
|
141
155
|
|
|
142
156
|
/**
|
|
143
|
-
* Track a conflict check result
|
|
157
|
+
* Track a conflict check result (legacy).
|
|
144
158
|
*/
|
|
145
159
|
export function trackConflict(root, hasConflict, blocked) {
|
|
146
160
|
if (!isTelemetryEnabled()) return;
|
|
161
|
+
try {
|
|
162
|
+
const store = readTelemetryStore(root);
|
|
163
|
+
store.conflicts.total++;
|
|
164
|
+
if (blocked) {
|
|
165
|
+
store.conflicts.blocked++;
|
|
166
|
+
} else if (hasConflict) {
|
|
167
|
+
store.conflicts.advisory++;
|
|
168
|
+
}
|
|
147
169
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
store.conflicts.blocked++;
|
|
152
|
-
} else if (hasConflict) {
|
|
153
|
-
store.conflicts.advisory++;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const today = new Date().toISOString().slice(0, 10);
|
|
157
|
-
if (!store.daily[today]) store.daily[today] = { calls: 0, conflicts: 0 };
|
|
158
|
-
if (hasConflict) store.daily[today].conflicts++;
|
|
170
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
171
|
+
if (!store.daily[today]) store.daily[today] = { calls: 0, conflicts: 0 };
|
|
172
|
+
if (hasConflict) store.daily[today].conflicts++;
|
|
159
173
|
|
|
160
|
-
|
|
161
|
-
|
|
174
|
+
store.updatedAt = new Date().toISOString();
|
|
175
|
+
writeTelemetryStore(root, store);
|
|
176
|
+
} catch { /* swallow */ }
|
|
162
177
|
}
|
|
163
178
|
|
|
164
179
|
/**
|
|
165
|
-
* Track feature adoption (
|
|
180
|
+
* Track feature adoption (legacy).
|
|
166
181
|
*/
|
|
167
182
|
export function trackFeature(root, featureName) {
|
|
168
183
|
if (!isTelemetryEnabled()) return;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
184
|
+
try {
|
|
185
|
+
const store = readTelemetryStore(root);
|
|
186
|
+
if (!store.features[featureName]) {
|
|
187
|
+
store.features[featureName] = { firstUsed: new Date().toISOString(), count: 0 };
|
|
188
|
+
}
|
|
189
|
+
store.features[featureName].count++;
|
|
190
|
+
store.features[featureName].lastUsed = new Date().toISOString();
|
|
191
|
+
store.updatedAt = new Date().toISOString();
|
|
192
|
+
writeTelemetryStore(root, store);
|
|
193
|
+
} catch { /* swallow */ }
|
|
179
194
|
}
|
|
180
195
|
|
|
181
196
|
/**
|
|
182
|
-
* Track session start
|
|
197
|
+
* Track a session start (legacy).
|
|
183
198
|
*/
|
|
184
199
|
export function trackSession(root, toolName) {
|
|
185
200
|
if (!isTelemetryEnabled()) return;
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
201
|
+
try {
|
|
202
|
+
const store = readTelemetryStore(root);
|
|
203
|
+
store.sessions.total++;
|
|
204
|
+
if (!store.sessions.tools[toolName]) store.sessions.tools[toolName] = 0;
|
|
205
|
+
store.sessions.tools[toolName]++;
|
|
206
|
+
store.updatedAt = new Date().toISOString();
|
|
207
|
+
writeTelemetryStore(root, store);
|
|
208
|
+
} catch { /* swallow */ }
|
|
194
209
|
}
|
|
195
210
|
|
|
196
|
-
// --- Analytics / Reporting ---
|
|
197
|
-
|
|
198
211
|
/**
|
|
199
|
-
* Get telemetry summary for dashboard display
|
|
212
|
+
* Get telemetry summary for dashboard display (legacy).
|
|
200
213
|
*/
|
|
201
214
|
export function getTelemetrySummary(root) {
|
|
202
215
|
if (!isTelemetryEnabled()) {
|
|
203
|
-
return {
|
|
216
|
+
return {
|
|
217
|
+
enabled: false,
|
|
218
|
+
message:
|
|
219
|
+
"Telemetry is disabled. Set SPECLOCK_TELEMETRY=true or run 'speclock telemetry on' to enable.",
|
|
220
|
+
};
|
|
204
221
|
}
|
|
205
222
|
|
|
206
223
|
const store = readTelemetryStore(root);
|
|
207
224
|
|
|
208
|
-
// Top tools by usage
|
|
209
225
|
const topTools = Object.entries(store.toolUsage)
|
|
210
226
|
.sort(([, a], [, b]) => b.count - a.count)
|
|
211
227
|
.slice(0, 10)
|
|
212
228
|
.map(([name, data]) => ({ name, ...data }));
|
|
213
229
|
|
|
214
|
-
// Daily trend (last 7 days)
|
|
215
230
|
const days = [];
|
|
216
231
|
for (let i = 6; i >= 0; i--) {
|
|
217
232
|
const d = new Date();
|
|
@@ -220,7 +235,6 @@ export function getTelemetrySummary(root) {
|
|
|
220
235
|
days.push({ date: key, ...(store.daily[key] || { calls: 0, conflicts: 0 }) });
|
|
221
236
|
}
|
|
222
237
|
|
|
223
|
-
// Feature adoption
|
|
224
238
|
const features = Object.entries(store.features)
|
|
225
239
|
.sort(([, a], [, b]) => b.count - a.count)
|
|
226
240
|
.map(([name, data]) => ({ name, ...data }));
|
|
@@ -239,11 +253,9 @@ export function getTelemetrySummary(root) {
|
|
|
239
253
|
};
|
|
240
254
|
}
|
|
241
255
|
|
|
242
|
-
// --- Remote telemetry (optional) ---
|
|
243
|
-
|
|
244
256
|
/**
|
|
245
|
-
* Flush telemetry to remote endpoint if configured.
|
|
246
|
-
*
|
|
257
|
+
* Flush legacy telemetry to a remote endpoint (if configured).
|
|
258
|
+
* Kept for backward compatibility with code that calls it directly.
|
|
247
259
|
*/
|
|
248
260
|
export async function flushToRemote(root) {
|
|
249
261
|
if (!isTelemetryEnabled()) return { sent: false, reason: "disabled" };
|
|
@@ -254,16 +266,15 @@ export async function flushToRemote(root) {
|
|
|
254
266
|
const summary = getTelemetrySummary(root);
|
|
255
267
|
if (!summary.enabled) return { sent: false, reason: "disabled" };
|
|
256
268
|
|
|
257
|
-
// Build anonymized payload
|
|
258
269
|
const payload = {
|
|
259
270
|
instanceId: summary.instanceId,
|
|
260
|
-
version:
|
|
271
|
+
version: getSpeclockVersion(),
|
|
261
272
|
totalCalls: summary.totalCalls,
|
|
262
273
|
avgResponseMs: summary.avgResponseMs,
|
|
263
274
|
conflicts: summary.conflicts,
|
|
264
275
|
sessions: summary.sessions,
|
|
265
|
-
topTools: summary.topTools.map(t => ({ name: t.name, count: t.count })),
|
|
266
|
-
features: summary.features.map(f => ({ name: f.name, count: f.count })),
|
|
276
|
+
topTools: summary.topTools.map((t) => ({ name: t.name, count: t.count })),
|
|
277
|
+
features: summary.features.map((f) => ({ name: f.name, count: f.count })),
|
|
267
278
|
timestamp: new Date().toISOString(),
|
|
268
279
|
};
|
|
269
280
|
|
|
@@ -272,10 +283,570 @@ export async function flushToRemote(root) {
|
|
|
272
283
|
method: "POST",
|
|
273
284
|
headers: { "Content-Type": "application/json" },
|
|
274
285
|
body: JSON.stringify(payload),
|
|
275
|
-
signal: AbortSignal.timeout(
|
|
286
|
+
signal: AbortSignal.timeout(1000),
|
|
276
287
|
});
|
|
277
288
|
return { sent: true, status: response.status };
|
|
278
289
|
} catch {
|
|
279
290
|
return { sent: false, reason: "network error" };
|
|
280
291
|
}
|
|
281
292
|
}
|
|
293
|
+
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
// GLOBAL OPT-IN CLI TELEMETRY (investor-requested)
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
//
|
|
298
|
+
// This layer is what the `speclock telemetry on|off|status|clear` commands
|
|
299
|
+
// operate on, and is what the CLI entrypoint feeds via recordCommand().
|
|
300
|
+
// It lives under the user's home directory so the decision persists across
|
|
301
|
+
// projects:
|
|
302
|
+
//
|
|
303
|
+
// ~/.speclock/install-id single-line random UUID, generated once
|
|
304
|
+
// ~/.speclock/telemetry.json { enabled: bool, decidedAt: iso, installedAt: iso }
|
|
305
|
+
// ~/.speclock/telemetry.jsonl append-only JSON-lines event log
|
|
306
|
+
//
|
|
307
|
+
// The default remote endpoint is the SpecLock Railway deploy. It can be
|
|
308
|
+
// overridden with SPECLOCK_TELEMETRY_ENDPOINT, or disabled entirely by
|
|
309
|
+
// setting SPECLOCK_TELEMETRY_ENDPOINT=off. When unreachable, events are
|
|
310
|
+
// still written locally so the data shape can be validated without a server.
|
|
311
|
+
|
|
312
|
+
export const TELEMETRY_DEFAULT_ENDPOINT =
|
|
313
|
+
"https://speclock-mcp-production.up.railway.app/telemetry";
|
|
314
|
+
|
|
315
|
+
function homeDir() {
|
|
316
|
+
try {
|
|
317
|
+
return os.homedir();
|
|
318
|
+
} catch {
|
|
319
|
+
return process.env.HOME || process.env.USERPROFILE || ".";
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function getTelemetryDir() {
|
|
324
|
+
return path.join(homeDir(), ".speclock");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function ensureTelemetryDir() {
|
|
328
|
+
const dir = getTelemetryDir();
|
|
329
|
+
try {
|
|
330
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
331
|
+
} catch { /* swallow */ }
|
|
332
|
+
return dir;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function configPath() {
|
|
336
|
+
return path.join(getTelemetryDir(), "telemetry.json");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function eventsPath() {
|
|
340
|
+
return path.join(getTelemetryDir(), "telemetry.jsonl");
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function installIdPath() {
|
|
344
|
+
return path.join(getTelemetryDir(), "install-id");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Read the global telemetry config file. If missing, returns undecided state.
|
|
349
|
+
*/
|
|
350
|
+
export function readTelemetryConfig() {
|
|
351
|
+
try {
|
|
352
|
+
const p = configPath();
|
|
353
|
+
if (!fs.existsSync(p)) return { enabled: null, decidedAt: null, installedAt: null };
|
|
354
|
+
const raw = fs.readFileSync(p, "utf-8");
|
|
355
|
+
const parsed = JSON.parse(raw);
|
|
356
|
+
return {
|
|
357
|
+
enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : null,
|
|
358
|
+
decidedAt: parsed.decidedAt || null,
|
|
359
|
+
installedAt: parsed.installedAt || null,
|
|
360
|
+
};
|
|
361
|
+
} catch {
|
|
362
|
+
return { enabled: null, decidedAt: null, installedAt: null };
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Persist the global telemetry config file. Always merges into any existing
|
|
368
|
+
* fields (e.g. installedAt is written only once).
|
|
369
|
+
*/
|
|
370
|
+
export function writeTelemetryConfig(patch) {
|
|
371
|
+
try {
|
|
372
|
+
ensureTelemetryDir();
|
|
373
|
+
const current = readTelemetryConfig();
|
|
374
|
+
const next = { ...current, ...patch };
|
|
375
|
+
if (!next.installedAt) next.installedAt = new Date().toISOString();
|
|
376
|
+
fs.writeFileSync(configPath(), JSON.stringify(next, null, 2));
|
|
377
|
+
return next;
|
|
378
|
+
} catch {
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Ensures an install id exists and returns it. This is the ONLY stable
|
|
385
|
+
* identifier we emit. It is a random UUID — never contains PII.
|
|
386
|
+
*/
|
|
387
|
+
export function getInstallId() {
|
|
388
|
+
try {
|
|
389
|
+
ensureTelemetryDir();
|
|
390
|
+
const p = installIdPath();
|
|
391
|
+
if (fs.existsSync(p)) {
|
|
392
|
+
const id = fs.readFileSync(p, "utf-8").trim();
|
|
393
|
+
if (id) return id;
|
|
394
|
+
}
|
|
395
|
+
const id =
|
|
396
|
+
typeof crypto.randomUUID === "function"
|
|
397
|
+
? crypto.randomUUID()
|
|
398
|
+
: crypto.randomBytes(16).toString("hex");
|
|
399
|
+
fs.writeFileSync(p, id);
|
|
400
|
+
// Bootstrap installedAt if we just created the id.
|
|
401
|
+
const cfg = readTelemetryConfig();
|
|
402
|
+
if (!cfg.installedAt) writeTelemetryConfig({ installedAt: new Date().toISOString() });
|
|
403
|
+
return id;
|
|
404
|
+
} catch {
|
|
405
|
+
return "unknown";
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Returns true if the user has opted in globally. Honours the SPECLOCK_TELEMETRY
|
|
411
|
+
* env var as a strong override so CI and one-off runs can force-enable without
|
|
412
|
+
* touching disk. SPECLOCK_TELEMETRY=0/false forces disabled.
|
|
413
|
+
*/
|
|
414
|
+
export function isTelemetryOptedIn() {
|
|
415
|
+
try {
|
|
416
|
+
const env = process.env.SPECLOCK_TELEMETRY;
|
|
417
|
+
if (env === "1" || env === "true") return true;
|
|
418
|
+
if (env === "0" || env === "false") return false;
|
|
419
|
+
const cfg = readTelemetryConfig();
|
|
420
|
+
return cfg.enabled === true;
|
|
421
|
+
} catch {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Returns true when no decision has been made yet (no prompt shown, no env var).
|
|
428
|
+
*/
|
|
429
|
+
export function hasTelemetryDecision() {
|
|
430
|
+
try {
|
|
431
|
+
const env = process.env.SPECLOCK_TELEMETRY;
|
|
432
|
+
if (env === "1" || env === "true" || env === "0" || env === "false") return true;
|
|
433
|
+
const cfg = readTelemetryConfig();
|
|
434
|
+
return cfg.enabled !== null;
|
|
435
|
+
} catch {
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Opt in (persistent). Returns the updated config.
|
|
442
|
+
*/
|
|
443
|
+
export function enableTelemetry() {
|
|
444
|
+
getInstallId();
|
|
445
|
+
return writeTelemetryConfig({ enabled: true, decidedAt: new Date().toISOString() });
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Opt out (persistent). Returns the updated config.
|
|
450
|
+
*/
|
|
451
|
+
export function disableTelemetry() {
|
|
452
|
+
return writeTelemetryConfig({ enabled: false, decidedAt: new Date().toISOString() });
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Clear the local event log. Does not change opt-in state.
|
|
457
|
+
*/
|
|
458
|
+
export function clearTelemetryLog() {
|
|
459
|
+
try {
|
|
460
|
+
const p = eventsPath();
|
|
461
|
+
if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
462
|
+
return { cleared: true };
|
|
463
|
+
} catch {
|
|
464
|
+
return { cleared: false };
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Read the last N events from the local log (most recent last).
|
|
470
|
+
*/
|
|
471
|
+
export function readRecentEvents(limit = 10) {
|
|
472
|
+
try {
|
|
473
|
+
const p = eventsPath();
|
|
474
|
+
if (!fs.existsSync(p)) return [];
|
|
475
|
+
const raw = fs.readFileSync(p, "utf-8");
|
|
476
|
+
const lines = raw.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
|
477
|
+
const slice = lines.slice(-limit);
|
|
478
|
+
return slice
|
|
479
|
+
.map((l) => {
|
|
480
|
+
try { return JSON.parse(l); } catch { return null; }
|
|
481
|
+
})
|
|
482
|
+
.filter(Boolean);
|
|
483
|
+
} catch {
|
|
484
|
+
return [];
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Count all events recorded in the local log.
|
|
490
|
+
*/
|
|
491
|
+
export function countTelemetryEvents() {
|
|
492
|
+
try {
|
|
493
|
+
const p = eventsPath();
|
|
494
|
+
if (!fs.existsSync(p)) return 0;
|
|
495
|
+
const raw = fs.readFileSync(p, "utf-8");
|
|
496
|
+
return raw.split(/\r?\n/).filter((l) => l.trim().length > 0).length;
|
|
497
|
+
} catch {
|
|
498
|
+
return 0;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// --- Context collection (anonymous, non-PII only) ---
|
|
503
|
+
|
|
504
|
+
function getSpeclockVersion() {
|
|
505
|
+
try {
|
|
506
|
+
// Resolve the package.json of the installed speclock module relative
|
|
507
|
+
// to this file. Works both in the source tree and when installed via npm.
|
|
508
|
+
const here = path.dirname(new URL(import.meta.url).pathname);
|
|
509
|
+
// On win32, URL pathname starts with "/C:/..." — strip the leading slash.
|
|
510
|
+
const normalised = process.platform === "win32" && here.startsWith("/")
|
|
511
|
+
? here.slice(1)
|
|
512
|
+
: here;
|
|
513
|
+
const pkgPath = path.resolve(normalised, "..", "..", "package.json");
|
|
514
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
515
|
+
return pkg.version || "unknown";
|
|
516
|
+
} catch {
|
|
517
|
+
return "unknown";
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function daysSinceInstall() {
|
|
522
|
+
try {
|
|
523
|
+
const cfg = readTelemetryConfig();
|
|
524
|
+
if (!cfg.installedAt) return 0;
|
|
525
|
+
const installed = new Date(cfg.installedAt).getTime();
|
|
526
|
+
const now = Date.now();
|
|
527
|
+
return Math.max(0, Math.floor((now - installed) / (24 * 60 * 60 * 1000)));
|
|
528
|
+
} catch {
|
|
529
|
+
return 0;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function countLocksInProject(projectRoot) {
|
|
534
|
+
try {
|
|
535
|
+
const brainPath = path.join(projectRoot, ".speclock", "brain.json");
|
|
536
|
+
if (!fs.existsSync(brainPath)) return 0;
|
|
537
|
+
const brain = JSON.parse(fs.readFileSync(brainPath, "utf-8"));
|
|
538
|
+
const items = brain && brain.specLock && Array.isArray(brain.specLock.items)
|
|
539
|
+
? brain.specLock.items
|
|
540
|
+
: [];
|
|
541
|
+
return items.filter((l) => l && l.active !== false).length;
|
|
542
|
+
} catch {
|
|
543
|
+
return 0;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function countRuleFilesInProject(projectRoot) {
|
|
548
|
+
// Keep the list in sync with guardian.js RULE_FILES (we duplicate it here
|
|
549
|
+
// so telemetry has zero runtime dependency on guardian).
|
|
550
|
+
const candidates = [
|
|
551
|
+
".cursorrules",
|
|
552
|
+
".cursor/rules/rules.mdc",
|
|
553
|
+
"CLAUDE.md",
|
|
554
|
+
"AGENTS.md",
|
|
555
|
+
".github/copilot-instructions.md",
|
|
556
|
+
".windsurfrules",
|
|
557
|
+
".windsurf/rules/rules.md",
|
|
558
|
+
"GEMINI.md",
|
|
559
|
+
".aider.conf.yml",
|
|
560
|
+
"COPILOT.md",
|
|
561
|
+
".github/instructions.md",
|
|
562
|
+
];
|
|
563
|
+
let count = 0;
|
|
564
|
+
for (const rel of candidates) {
|
|
565
|
+
try {
|
|
566
|
+
const full = path.join(projectRoot, rel);
|
|
567
|
+
if (fs.existsSync(full)) count++;
|
|
568
|
+
} catch { /* swallow */ }
|
|
569
|
+
}
|
|
570
|
+
return count;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function getEnforcementModeForProject(projectRoot) {
|
|
574
|
+
try {
|
|
575
|
+
const brainPath = path.join(projectRoot, ".speclock", "brain.json");
|
|
576
|
+
if (!fs.existsSync(brainPath)) return "unknown";
|
|
577
|
+
const brain = JSON.parse(fs.readFileSync(brainPath, "utf-8"));
|
|
578
|
+
const mode = brain && brain.enforcement && brain.enforcement.mode;
|
|
579
|
+
if (mode === "hard") return "hard";
|
|
580
|
+
if (mode === "advisory") return "warn";
|
|
581
|
+
return "warn"; // default
|
|
582
|
+
} catch {
|
|
583
|
+
return "unknown";
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Detect which supported MCP clients have SpecLock wired up. Returns an
|
|
589
|
+
* array of client names (e.g. ["claude-code", "cursor"]). No file content
|
|
590
|
+
* is read except to look for the substring "speclock" in a JSON/TOML blob.
|
|
591
|
+
* Never reads from the project directory.
|
|
592
|
+
*/
|
|
593
|
+
function detectMcpClientsConfigured() {
|
|
594
|
+
const home = homeDir();
|
|
595
|
+
const platform = process.platform;
|
|
596
|
+
const checks = [
|
|
597
|
+
{ name: "claude-code", p: path.join(home, ".claude", "mcp.json") },
|
|
598
|
+
{ name: "cursor", p: path.join(home, ".cursor", "mcp.json") },
|
|
599
|
+
{ name: "windsurf", p: path.join(home, ".codeium", "windsurf", "mcp_config.json") },
|
|
600
|
+
{ name: "codex", p: path.join(home, ".codex", "config.toml") },
|
|
601
|
+
];
|
|
602
|
+
|
|
603
|
+
// Cline lives inside VS Code User settings.json.
|
|
604
|
+
if (platform === "win32") {
|
|
605
|
+
checks.push({
|
|
606
|
+
name: "cline",
|
|
607
|
+
p: path.join(
|
|
608
|
+
process.env.APPDATA || path.join(home, "AppData", "Roaming"),
|
|
609
|
+
"Code",
|
|
610
|
+
"User",
|
|
611
|
+
"settings.json"
|
|
612
|
+
),
|
|
613
|
+
});
|
|
614
|
+
} else if (platform === "darwin") {
|
|
615
|
+
checks.push({
|
|
616
|
+
name: "cline",
|
|
617
|
+
p: path.join(home, "Library", "Application Support", "Code", "User", "settings.json"),
|
|
618
|
+
});
|
|
619
|
+
} else {
|
|
620
|
+
checks.push({
|
|
621
|
+
name: "cline",
|
|
622
|
+
p: path.join(home, ".config", "Code", "User", "settings.json"),
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const found = [];
|
|
627
|
+
for (const c of checks) {
|
|
628
|
+
try {
|
|
629
|
+
if (!fs.existsSync(c.p)) continue;
|
|
630
|
+
const raw = fs.readFileSync(c.p, "utf-8");
|
|
631
|
+
if (raw && raw.toLowerCase().includes("speclock")) {
|
|
632
|
+
found.push(c.name);
|
|
633
|
+
}
|
|
634
|
+
} catch { /* swallow */ }
|
|
635
|
+
}
|
|
636
|
+
return found;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Build the anonymous payload for a single command invocation.
|
|
641
|
+
* Public so `telemetry status` can show exactly what would be sent.
|
|
642
|
+
*/
|
|
643
|
+
export function buildTelemetryEvent({
|
|
644
|
+
command,
|
|
645
|
+
exitCode,
|
|
646
|
+
projectRoot = process.cwd(),
|
|
647
|
+
extra = {},
|
|
648
|
+
} = {}) {
|
|
649
|
+
return {
|
|
650
|
+
installId: getInstallId(),
|
|
651
|
+
version: getSpeclockVersion(),
|
|
652
|
+
os: process.platform,
|
|
653
|
+
nodeVersion: process.version,
|
|
654
|
+
command: command || "unknown",
|
|
655
|
+
exitCode: typeof exitCode === "number" ? exitCode : 0,
|
|
656
|
+
enforcementMode: getEnforcementModeForProject(projectRoot),
|
|
657
|
+
lockCount: countLocksInProject(projectRoot),
|
|
658
|
+
ruleFilesFound: countRuleFilesInProject(projectRoot),
|
|
659
|
+
mcpClientsConfigured: detectMcpClientsConfigured(),
|
|
660
|
+
daysSinceInstall: daysSinceInstall(),
|
|
661
|
+
timestamp: new Date().toISOString(),
|
|
662
|
+
...extra,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Append an event to the local JSONL log.
|
|
668
|
+
*/
|
|
669
|
+
function appendEvent(event) {
|
|
670
|
+
try {
|
|
671
|
+
ensureTelemetryDir();
|
|
672
|
+
fs.appendFileSync(eventsPath(), JSON.stringify(event) + "\n");
|
|
673
|
+
} catch { /* swallow */ }
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Send an event to the remote endpoint with a 1-second timeout.
|
|
678
|
+
* Silently swallows all errors. Returns a Promise that never rejects.
|
|
679
|
+
*/
|
|
680
|
+
async function sendEventRemote(event) {
|
|
681
|
+
try {
|
|
682
|
+
const raw = process.env.SPECLOCK_TELEMETRY_ENDPOINT;
|
|
683
|
+
if (raw === "off" || raw === "none" || raw === "0") return;
|
|
684
|
+
const endpoint = raw || TELEMETRY_DEFAULT_ENDPOINT;
|
|
685
|
+
|
|
686
|
+
let signal;
|
|
687
|
+
try {
|
|
688
|
+
if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") {
|
|
689
|
+
signal = AbortSignal.timeout(1000);
|
|
690
|
+
} else {
|
|
691
|
+
const ctl = new AbortController();
|
|
692
|
+
setTimeout(() => ctl.abort(), 1000).unref?.();
|
|
693
|
+
signal = ctl.signal;
|
|
694
|
+
}
|
|
695
|
+
} catch { /* ignore — send without signal */ }
|
|
696
|
+
|
|
697
|
+
await fetch(endpoint, {
|
|
698
|
+
method: "POST",
|
|
699
|
+
headers: { "Content-Type": "application/json" },
|
|
700
|
+
body: JSON.stringify(event),
|
|
701
|
+
signal,
|
|
702
|
+
});
|
|
703
|
+
} catch { /* swallow every failure */ }
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Record a command invocation. Fire-and-forget: the caller is never blocked,
|
|
708
|
+
* no error ever reaches them. Writes to local JSONL immediately so the data
|
|
709
|
+
* shape can be validated without a running server, and also attempts a
|
|
710
|
+
* best-effort remote send behind a 1-second timeout.
|
|
711
|
+
*
|
|
712
|
+
* Usage from the CLI entrypoint:
|
|
713
|
+
*
|
|
714
|
+
* recordCommand("protect", 0);
|
|
715
|
+
*
|
|
716
|
+
* This function is safe to call from inside a `process.on('exit')` handler:
|
|
717
|
+
* the local JSONL write happens synchronously so it completes before the
|
|
718
|
+
* process terminates, while the remote HTTP send is scheduled via
|
|
719
|
+
* setImmediate (which will no-op on exit, but that's fine — the local log
|
|
720
|
+
* is the source of truth for validating the data shape).
|
|
721
|
+
*
|
|
722
|
+
* Safe to call even when telemetry is disabled — it will simply no-op.
|
|
723
|
+
*/
|
|
724
|
+
export function recordCommand(command, exitCode, opts = {}) {
|
|
725
|
+
try {
|
|
726
|
+
if (!isTelemetryOptedIn()) return;
|
|
727
|
+
const event = buildTelemetryEvent({
|
|
728
|
+
command,
|
|
729
|
+
exitCode,
|
|
730
|
+
projectRoot: opts.projectRoot || process.cwd(),
|
|
731
|
+
extra: opts.extra || {},
|
|
732
|
+
});
|
|
733
|
+
// Synchronous local write — must complete even if called from an
|
|
734
|
+
// 'exit' handler where microtasks/timers are no longer scheduled.
|
|
735
|
+
try { appendEvent(event); } catch { /* swallow */ }
|
|
736
|
+
// Best-effort remote send — fire-and-forget behind a 1s timeout.
|
|
737
|
+
try {
|
|
738
|
+
if (typeof setImmediate === "function") {
|
|
739
|
+
setImmediate(() => { sendEventRemote(event); });
|
|
740
|
+
} else {
|
|
741
|
+
setTimeout(() => { sendEventRemote(event); }, 0);
|
|
742
|
+
}
|
|
743
|
+
} catch { /* swallow */ }
|
|
744
|
+
} catch { /* swallow */ }
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Human-readable summary of the current opt-in state + last events.
|
|
749
|
+
* Used by `speclock telemetry status`.
|
|
750
|
+
*/
|
|
751
|
+
export function getOptInTelemetryStatus({ eventLimit = 10 } = {}) {
|
|
752
|
+
try {
|
|
753
|
+
const cfg = readTelemetryConfig();
|
|
754
|
+
const envOverride = process.env.SPECLOCK_TELEMETRY;
|
|
755
|
+
const enabled = isTelemetryOptedIn();
|
|
756
|
+
return {
|
|
757
|
+
enabled,
|
|
758
|
+
decided: hasTelemetryDecision(),
|
|
759
|
+
decidedAt: cfg.decidedAt,
|
|
760
|
+
installedAt: cfg.installedAt,
|
|
761
|
+
installId: getInstallId(),
|
|
762
|
+
configPath: configPath(),
|
|
763
|
+
eventsPath: eventsPath(),
|
|
764
|
+
envOverride: envOverride || null,
|
|
765
|
+
endpoint:
|
|
766
|
+
process.env.SPECLOCK_TELEMETRY_ENDPOINT === "off"
|
|
767
|
+
? null
|
|
768
|
+
: process.env.SPECLOCK_TELEMETRY_ENDPOINT || TELEMETRY_DEFAULT_ENDPOINT,
|
|
769
|
+
eventCount: countTelemetryEvents(),
|
|
770
|
+
recentEvents: readRecentEvents(eventLimit),
|
|
771
|
+
sampleEvent: buildTelemetryEvent({ command: "<sample>", exitCode: 0 }),
|
|
772
|
+
};
|
|
773
|
+
} catch {
|
|
774
|
+
return {
|
|
775
|
+
enabled: false,
|
|
776
|
+
decided: false,
|
|
777
|
+
decidedAt: null,
|
|
778
|
+
installedAt: null,
|
|
779
|
+
installId: "unknown",
|
|
780
|
+
configPath: configPath(),
|
|
781
|
+
eventsPath: eventsPath(),
|
|
782
|
+
envOverride: null,
|
|
783
|
+
endpoint: TELEMETRY_DEFAULT_ENDPOINT,
|
|
784
|
+
eventCount: 0,
|
|
785
|
+
recentEvents: [],
|
|
786
|
+
sampleEvent: null,
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Prompts the user on stdin with a Y/N question. Resolves to true on "y"/"yes",
|
|
793
|
+
* false otherwise. Defaults to false if stdin is not a TTY or an error occurs.
|
|
794
|
+
*/
|
|
795
|
+
export function promptTelemetryOptIn() {
|
|
796
|
+
return new Promise((resolve) => {
|
|
797
|
+
try {
|
|
798
|
+
if (!process.stdin.isTTY) return resolve(false);
|
|
799
|
+
process.stdout.write(`
|
|
800
|
+
Help improve SpecLock?
|
|
801
|
+
We collect anonymous usage data to understand which features matter.
|
|
802
|
+
We NEVER collect: file contents, commit messages, lock content, paths, names.
|
|
803
|
+
See: speclock telemetry status
|
|
804
|
+
|
|
805
|
+
Enable telemetry? [y/N]: `);
|
|
806
|
+
let buf = "";
|
|
807
|
+
const onData = (chunk) => {
|
|
808
|
+
buf += chunk.toString();
|
|
809
|
+
if (buf.includes("\n")) {
|
|
810
|
+
process.stdin.removeListener("data", onData);
|
|
811
|
+
process.stdin.pause();
|
|
812
|
+
const answer = buf.trim().toLowerCase();
|
|
813
|
+
resolve(answer === "y" || answer === "yes");
|
|
814
|
+
}
|
|
815
|
+
};
|
|
816
|
+
process.stdin.resume();
|
|
817
|
+
process.stdin.on("data", onData);
|
|
818
|
+
// Safety timeout — never block forever.
|
|
819
|
+
setTimeout(() => {
|
|
820
|
+
try { process.stdin.removeListener("data", onData); } catch {}
|
|
821
|
+
try { process.stdin.pause(); } catch {}
|
|
822
|
+
resolve(false);
|
|
823
|
+
}, 15000).unref?.();
|
|
824
|
+
} catch {
|
|
825
|
+
resolve(false);
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Ensures a telemetry decision has been recorded. If none exists and we are
|
|
832
|
+
* attached to a TTY, prompts the user. Defaults to OFF for any non-interactive
|
|
833
|
+
* shell or on any error. Always persists the decision so we never prompt twice.
|
|
834
|
+
* Resolves to the final opt-in boolean.
|
|
835
|
+
*/
|
|
836
|
+
export async function ensureTelemetryDecision() {
|
|
837
|
+
try {
|
|
838
|
+
if (hasTelemetryDecision()) return isTelemetryOptedIn();
|
|
839
|
+
const answer = await promptTelemetryOptIn();
|
|
840
|
+
if (answer) {
|
|
841
|
+
enableTelemetry();
|
|
842
|
+
console.log("Telemetry: ENABLED. Thank you! Run 'speclock telemetry off' to disable any time.");
|
|
843
|
+
return true;
|
|
844
|
+
} else {
|
|
845
|
+
disableTelemetry();
|
|
846
|
+
console.log("Telemetry: DISABLED. Run 'speclock telemetry on' to enable any time.");
|
|
847
|
+
return false;
|
|
848
|
+
}
|
|
849
|
+
} catch {
|
|
850
|
+
return false;
|
|
851
|
+
}
|
|
852
|
+
}
|