great-cto 2.69.0 → 2.70.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/main.js +110 -14
- package/dist/telemetry.js +217 -0
- package/package.json +1 -1
package/dist/main.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// 7. bootstrap .great_cto/PROJECT.md
|
|
12
12
|
// 8. print next steps
|
|
13
13
|
import { resolve } from "node:path";
|
|
14
|
-
import { banner, bold, cyan, dim, error, green, log, step, success, warn, yellow, confirm } from "./ui.js";
|
|
14
|
+
import { banner, bold, cyan, dim, error, gray, green, log, step, success, warn, yellow, confirm } from "./ui.js";
|
|
15
15
|
import { detect } from "./detect.js";
|
|
16
16
|
import { pickArchetype, suggestCompliance } from "./archetypes.js";
|
|
17
17
|
import { install, findInstalledVersions } from "./installer.js";
|
|
@@ -20,6 +20,7 @@ import { installAllCompanions } from "./companion.js";
|
|
|
20
20
|
import { bootstrap } from "./bootstrap.js";
|
|
21
21
|
import { compileFlow } from "./flow.js";
|
|
22
22
|
import { shouldUseLlmFallback, suggestArchetypeFromLlm } from "./llm-fallback.js";
|
|
23
|
+
import { sendUsagePing, sendInstallPing, telemetrySubcommand, isTelemetryEnabled, computeAnonId } from "./telemetry.js";
|
|
23
24
|
import { readFileSync, writeFileSync, copyFileSync, chmodSync, mkdirSync, readdirSync, unlinkSync, existsSync as fsExistsSync } from "node:fs";
|
|
24
25
|
import { dirname, join } from "node:path";
|
|
25
26
|
import { fileURLToPath } from "node:url";
|
|
@@ -96,6 +97,8 @@ function parseArgs(argv) {
|
|
|
96
97
|
args.command = "board";
|
|
97
98
|
else if (a === "console")
|
|
98
99
|
args.command = "console";
|
|
100
|
+
else if (a === "telemetry")
|
|
101
|
+
args.command = "telemetry";
|
|
99
102
|
else if (a === "register")
|
|
100
103
|
args.command = "register";
|
|
101
104
|
else if (a === "ci")
|
|
@@ -213,7 +216,13 @@ function findBoardServerPath() {
|
|
|
213
216
|
const pluginBase = join(homedir(), ".claude", "plugins", "cache", "local", "great_cto");
|
|
214
217
|
if (fsExistsSync(pluginBase)) {
|
|
215
218
|
try {
|
|
216
|
-
|
|
219
|
+
// Numeric semver sort — a plain .sort() is lexicographic and would rank
|
|
220
|
+
// 2.99.0 above 2.100.0 (and once ranked 2.7.0 above 2.69.0).
|
|
221
|
+
const byVer = (a, b) => {
|
|
222
|
+
const pa = a.split(".").map(Number), pb = b.split(".").map(Number);
|
|
223
|
+
return (pb[0] - pa[0]) || (pb[1] - pa[1]) || (pb[2] - pa[2]) || 0;
|
|
224
|
+
};
|
|
225
|
+
const versions = readdirSync(pluginBase).filter(v => /^\d/.test(v)).sort(byVer);
|
|
217
226
|
for (const v of versions.slice(0, 5)) {
|
|
218
227
|
candidates.push(join(pluginBase, v, "packages", "board", "server.mjs"));
|
|
219
228
|
}
|
|
@@ -373,6 +382,13 @@ ${bold("Operator console (the second surface — invite-only, hostable):")}
|
|
|
373
382
|
great-cto console --bind 0.0.0.0 Reachable beyond this machine (tunnel/hosting);
|
|
374
383
|
operators sign in via invite links
|
|
375
384
|
|
|
385
|
+
${bold("Telemetry (anonymous, opt-IN — OFF by default):")}
|
|
386
|
+
great-cto telemetry status Show state + endpoint + your anon_id
|
|
387
|
+
great-cto telemetry on Enable anonymous usage events (command, node, os — no PII)
|
|
388
|
+
great-cto telemetry off Disable ${dim("(also: DO_NOT_TRACK=1)")}
|
|
389
|
+
great-cto telemetry whoami Print your anon_id (8 hex chars, not reversible)
|
|
390
|
+
${dim("Privacy policy: docs/PRIVACY.md")}
|
|
391
|
+
|
|
376
392
|
${bold("Register:")}
|
|
377
393
|
great-cto register Add this repo to ~/.great_cto/projects.json
|
|
378
394
|
(auto-discovered after /audit or /start, but
|
|
@@ -892,6 +908,13 @@ async function runInit(args) {
|
|
|
892
908
|
}
|
|
893
909
|
// ── 6. install pre-push git hook ─────────────────────────
|
|
894
910
|
installPrePushHook(args.dir);
|
|
911
|
+
// ── 7. opt-IN telemetry prompt (default OFF) ─────────────
|
|
912
|
+
await promoteTelemetryOptIn({ archetype: String(archetype), cliVersion: getCliVersion(), yes: args.yes });
|
|
913
|
+
// If already enabled (prior opt-in or env var), count this (re)install toward MAU.
|
|
914
|
+
if (isTelemetryEnabled()) {
|
|
915
|
+
await sendInstallPing({ cliVersion: getCliVersion(), archetype: String(archetype), consent: true })
|
|
916
|
+
.catch(() => { });
|
|
917
|
+
}
|
|
895
918
|
// ── done ─────────────────────────────────────────────────
|
|
896
919
|
log("");
|
|
897
920
|
log(green(bold("✓ great_cto is ready.")));
|
|
@@ -907,6 +930,57 @@ async function runInit(args) {
|
|
|
907
930
|
log("");
|
|
908
931
|
return 0;
|
|
909
932
|
}
|
|
933
|
+
// ── Telemetry opt-IN prompt ────────────────────────────────────────────────
|
|
934
|
+
// Shown after a successful init when interactive. Skipped if: --yes, non-TTY,
|
|
935
|
+
// DO_NOT_TRACK=1, CI, or the user already decided (either way — no nagging).
|
|
936
|
+
async function promoteTelemetryOptIn(opts) {
|
|
937
|
+
if (opts.yes)
|
|
938
|
+
return;
|
|
939
|
+
if (!process.stdin.isTTY)
|
|
940
|
+
return;
|
|
941
|
+
if (process.env.DO_NOT_TRACK === "1" || process.env.DO_NOT_TRACK === "true")
|
|
942
|
+
return;
|
|
943
|
+
if (process.env.CI || process.env.GITHUB_ACTIONS)
|
|
944
|
+
return;
|
|
945
|
+
const cfgFile = join(homedir(), ".great_cto", "telemetry.json");
|
|
946
|
+
if (fsExistsSync(cfgFile)) {
|
|
947
|
+
try {
|
|
948
|
+
const cfg = JSON.parse(readFileSync(cfgFile, "utf8"));
|
|
949
|
+
if (cfg.enabled === true || cfg.enabled === false)
|
|
950
|
+
return; // already decided
|
|
951
|
+
}
|
|
952
|
+
catch { /* malformed — ask again */ }
|
|
953
|
+
}
|
|
954
|
+
log(dim("─".repeat(60)));
|
|
955
|
+
log(bold("Help improve great_cto with anonymous usage data?"));
|
|
956
|
+
log("");
|
|
957
|
+
log(dim("Default: OFF. One event per command — exactly this, nothing more:"));
|
|
958
|
+
log("");
|
|
959
|
+
log(gray(` { "command": "init", "archetype": "${opts.archetype}", "version": "${opts.cliVersion}",`));
|
|
960
|
+
log(gray(` "node": "${process.version.replace(/^v/, "")}", "os": "${process.platform}",`));
|
|
961
|
+
log(gray(` "exit_code": 0, "duration_ms": 1234, "anon_id": "${computeAnonId()}" }`));
|
|
962
|
+
log("");
|
|
963
|
+
log(dim("No code, no repo names, no file paths, no IP, no PII. ") + dim("anon_id is sha256(user@host), not reversible."));
|
|
964
|
+
log(dim("Toggle anytime: " + cyan("npx great-cto telemetry off") + " · honors " + cyan("DO_NOT_TRACK=1")));
|
|
965
|
+
log(dim("Privacy: " + cyan("github.com/avelikiy/great_cto/blob/main/docs/PRIVACY.md")));
|
|
966
|
+
log("");
|
|
967
|
+
const yes = await confirm(bold("Enable anonymous telemetry?"), false);
|
|
968
|
+
log("");
|
|
969
|
+
try {
|
|
970
|
+
mkdirSync(join(homedir(), ".great_cto"), { recursive: true });
|
|
971
|
+
writeFileSync(cfgFile, JSON.stringify({ enabled: yes, decided_at: new Date().toISOString() }, null, 2) + "\n");
|
|
972
|
+
}
|
|
973
|
+
catch { /* best-effort */ }
|
|
974
|
+
if (yes) {
|
|
975
|
+
log(green("✓ Telemetry enabled. Thank you."));
|
|
976
|
+
log(dim(` Your anon_id: ${cyan("npx great-cto telemetry whoami")} · off anytime: ${cyan("npx great-cto telemetry off")}`));
|
|
977
|
+
log("");
|
|
978
|
+
}
|
|
979
|
+
else {
|
|
980
|
+
log(dim("No telemetry. (You can opt in later with " + cyan("npx great-cto telemetry on") + ".)"));
|
|
981
|
+
log("");
|
|
982
|
+
}
|
|
983
|
+
}
|
|
910
984
|
/**
|
|
911
985
|
* Copy scripts/hooks/pre-push.sh from the installed plugin into the project's
|
|
912
986
|
* .git/hooks/pre-push so that future pushes are scanned for private project
|
|
@@ -972,9 +1046,31 @@ async function runUpgrade(rawArgv) {
|
|
|
972
1046
|
async function main() {
|
|
973
1047
|
const rawArgv = process.argv.slice(2);
|
|
974
1048
|
const args = parseArgs(rawArgv);
|
|
1049
|
+
// Anonymous, opt-IN usage telemetry (default OFF — see docs/PRIVACY.md). `finish`
|
|
1050
|
+
// is the single exit funnel: it fires one fire-and-forget event (command + node + os
|
|
1051
|
+
// + exit_code + duration, no PII) only when the user has opted in, then exits.
|
|
1052
|
+
const __tStart = Date.now();
|
|
1053
|
+
const finish = async (code) => {
|
|
1054
|
+
try {
|
|
1055
|
+
await sendUsagePing({
|
|
1056
|
+
cliVersion: getCliVersion(),
|
|
1057
|
+
subcommand: args.command,
|
|
1058
|
+
exitCode: code,
|
|
1059
|
+
durationMs: Date.now() - __tStart,
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
catch { /* telemetry never affects the exit */ }
|
|
1063
|
+
process.exit(code);
|
|
1064
|
+
};
|
|
1065
|
+
// `great-cto telemetry <on|off|status|whoami>` — inspect / toggle, never sends.
|
|
1066
|
+
if (args.command === "telemetry") {
|
|
1067
|
+
const { exitCode, output } = telemetrySubcommand(args.positional[0]);
|
|
1068
|
+
process.stdout.write(output);
|
|
1069
|
+
process.exit(exitCode);
|
|
1070
|
+
}
|
|
975
1071
|
if (args.command === "help") {
|
|
976
1072
|
printHelp();
|
|
977
|
-
|
|
1073
|
+
await finish(0);
|
|
978
1074
|
}
|
|
979
1075
|
if (args.command === "unknown") {
|
|
980
1076
|
const tok = args.unknownToken ?? "<arg>";
|
|
@@ -986,7 +1082,7 @@ async function main() {
|
|
|
986
1082
|
if (args.command === "board") {
|
|
987
1083
|
try {
|
|
988
1084
|
const code = await runBoard(args);
|
|
989
|
-
|
|
1085
|
+
await finish(code);
|
|
990
1086
|
}
|
|
991
1087
|
catch (e) {
|
|
992
1088
|
error(e.message);
|
|
@@ -996,7 +1092,7 @@ async function main() {
|
|
|
996
1092
|
if (args.command === "console") {
|
|
997
1093
|
try {
|
|
998
1094
|
const code = await runBoard(args, "console");
|
|
999
|
-
|
|
1095
|
+
await finish(code);
|
|
1000
1096
|
}
|
|
1001
1097
|
catch (e) {
|
|
1002
1098
|
error(e.message);
|
|
@@ -1006,7 +1102,7 @@ async function main() {
|
|
|
1006
1102
|
if (args.command === "register") {
|
|
1007
1103
|
try {
|
|
1008
1104
|
const code = await runRegister(args);
|
|
1009
|
-
|
|
1105
|
+
await finish(code);
|
|
1010
1106
|
}
|
|
1011
1107
|
catch (e) {
|
|
1012
1108
|
error(e.message);
|
|
@@ -1017,7 +1113,7 @@ async function main() {
|
|
|
1017
1113
|
try {
|
|
1018
1114
|
const { runCi, parseCiArgs } = await import("./ci.js");
|
|
1019
1115
|
const code = await runCi(parseCiArgs(rawArgv));
|
|
1020
|
-
|
|
1116
|
+
await finish(code);
|
|
1021
1117
|
}
|
|
1022
1118
|
catch (e) {
|
|
1023
1119
|
error(e.message);
|
|
@@ -1031,7 +1127,7 @@ async function main() {
|
|
|
1031
1127
|
const portArg = rawArgv.indexOf("--port");
|
|
1032
1128
|
const port = portArg >= 0 ? parseInt(rawArgv[portArg + 1] ?? "8765", 10) : 8765;
|
|
1033
1129
|
const code = await runMcp({ mode: sse ? "sse" : "stdio", port, version: getCliVersion() });
|
|
1034
|
-
|
|
1130
|
+
await finish(code);
|
|
1035
1131
|
}
|
|
1036
1132
|
catch (e) {
|
|
1037
1133
|
error(e.message);
|
|
@@ -1046,7 +1142,7 @@ async function main() {
|
|
|
1046
1142
|
dryRun: rawArgv.includes("--dry-run"),
|
|
1047
1143
|
cwd: args.dir,
|
|
1048
1144
|
});
|
|
1049
|
-
|
|
1145
|
+
await finish(code);
|
|
1050
1146
|
}
|
|
1051
1147
|
catch (e) {
|
|
1052
1148
|
error(e.message);
|
|
@@ -1064,7 +1160,7 @@ async function main() {
|
|
|
1064
1160
|
noLog: rawArgv.includes("--no-log"),
|
|
1065
1161
|
insecure: rawArgv.includes("--insecure"),
|
|
1066
1162
|
});
|
|
1067
|
-
|
|
1163
|
+
await finish(code);
|
|
1068
1164
|
}
|
|
1069
1165
|
catch (e) {
|
|
1070
1166
|
error(e.message);
|
|
@@ -1080,7 +1176,7 @@ async function main() {
|
|
|
1080
1176
|
process.exit(2);
|
|
1081
1177
|
}
|
|
1082
1178
|
const code = await runWebhookCli(parsed);
|
|
1083
|
-
|
|
1179
|
+
await finish(code);
|
|
1084
1180
|
}
|
|
1085
1181
|
catch (e) {
|
|
1086
1182
|
error(e.message);
|
|
@@ -1090,7 +1186,7 @@ async function main() {
|
|
|
1090
1186
|
if (args.command === "upgrade") {
|
|
1091
1187
|
try {
|
|
1092
1188
|
const code = await runUpgrade(rawArgv);
|
|
1093
|
-
|
|
1189
|
+
await finish(code);
|
|
1094
1190
|
}
|
|
1095
1191
|
catch (e) {
|
|
1096
1192
|
error(e.message);
|
|
@@ -1121,7 +1217,7 @@ async function main() {
|
|
|
1121
1217
|
process.exit(2);
|
|
1122
1218
|
}
|
|
1123
1219
|
const code = await runReport(parsed);
|
|
1124
|
-
|
|
1220
|
+
await finish(code);
|
|
1125
1221
|
}
|
|
1126
1222
|
catch (e) {
|
|
1127
1223
|
error(e.message);
|
|
@@ -1156,7 +1252,7 @@ async function main() {
|
|
|
1156
1252
|
}
|
|
1157
1253
|
try {
|
|
1158
1254
|
const code = await runInit(args);
|
|
1159
|
-
|
|
1255
|
+
await finish(code);
|
|
1160
1256
|
}
|
|
1161
1257
|
catch (e) {
|
|
1162
1258
|
error(e.message);
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Anonymous opt-IN telemetry — default OFF.
|
|
2
|
+
//
|
|
3
|
+
// See docs/PRIVACY.md for the full policy. Short version:
|
|
4
|
+
// - Default: disabled (opt-in)
|
|
5
|
+
// - Honors DO_NOT_TRACK=1 (industry standard, https://consoledonottrack.com)
|
|
6
|
+
// - Skipped automatically in CI environments
|
|
7
|
+
// - No paths, no code, no PII — just {ts, version, command, archetype, node, os, exit, duration_ms, anon_id}
|
|
8
|
+
// - anon_id is sha256(user@hostname) truncated to 8 hex chars; not reversible
|
|
9
|
+
//
|
|
10
|
+
// Opt-in (any one):
|
|
11
|
+
// GREAT_CTO_TELEMETRY=on (env var)
|
|
12
|
+
// ~/.great_cto/telemetry.json: { "enabled": true }
|
|
13
|
+
// npx great-cto telemetry on
|
|
14
|
+
//
|
|
15
|
+
// Opt-out (overrides everything):
|
|
16
|
+
// DO_NOT_TRACK=1 (highest priority)
|
|
17
|
+
// GREAT_CTO_TELEMETRY=off
|
|
18
|
+
// GREAT_CTO_DISABLE_TELEMETRY=1 (legacy alias from v2.x)
|
|
19
|
+
// GREATCTO_NO_TELEMETRY=1 (legacy alias from v2.x)
|
|
20
|
+
// ~/.great_cto/telemetry.json: { "enabled": false }
|
|
21
|
+
//
|
|
22
|
+
// Endpoint: https://telemetry.greatcto.systems/v1/event (Cloudflare Worker → D1)
|
|
23
|
+
// Worker: workers/telemetry/index.ts
|
|
24
|
+
// Schema v1: see docs/PRIVACY.md "What we collect"
|
|
25
|
+
import * as fs from "node:fs";
|
|
26
|
+
import * as path from "node:path";
|
|
27
|
+
import * as os from "node:os";
|
|
28
|
+
import * as crypto from "node:crypto";
|
|
29
|
+
const TELEMETRY_ENDPOINT = process.env.GREAT_CTO_TELEMETRY_ENDPOINT
|
|
30
|
+
|| "https://great-cto-telemetry.alexander-velikiy.workers.dev/v1/event";
|
|
31
|
+
// Note: workers.dev URL is the temporary default until telemetry.greatcto.systems
|
|
32
|
+
// custom domain is bound. Override anytime with GREAT_CTO_TELEMETRY_ENDPOINT.
|
|
33
|
+
const TELEMETRY_TIMEOUT_MS = 1000;
|
|
34
|
+
// Allowlist — anything else is dropped client-side and server-side.
|
|
35
|
+
const ALLOWED_COMMANDS = new Set([
|
|
36
|
+
"init", "ci", "board", "console", "register",
|
|
37
|
+
"adapt", "mcp", "report", "serve", "webhook", "upgrade",
|
|
38
|
+
"version", "help", "telemetry",
|
|
39
|
+
]);
|
|
40
|
+
// Allowlist for archetype field. Match the 25 documented + "none" + "unknown".
|
|
41
|
+
const ALLOWED_ARCHETYPES = new Set([
|
|
42
|
+
"none", "unknown", "greenfield",
|
|
43
|
+
"enterprise-saas", "agent-product", "ai-system", "mlops",
|
|
44
|
+
"cli-tool", "cli", "library", "sdk", "devtools",
|
|
45
|
+
"fintech", "regulated", "compliance",
|
|
46
|
+
"iot-embedded", "web3", "marketplace", "cms", "edtech",
|
|
47
|
+
"gov-public", "insurance", "data-platform", "streaming",
|
|
48
|
+
"mobile-app", "infra", "web-service", "agent",
|
|
49
|
+
]);
|
|
50
|
+
function configPath() {
|
|
51
|
+
return path.join(os.homedir(), ".great_cto", "telemetry.json");
|
|
52
|
+
}
|
|
53
|
+
function readConfig() {
|
|
54
|
+
// ONLY the new opt-in file counts. We deliberately do NOT honor the legacy
|
|
55
|
+
// ~/.great_cto/config.json "telemetry" flag: that consent predates the v2.9.2
|
|
56
|
+
// zero-telemetry reset, so silently reactivating it would re-enable collection
|
|
57
|
+
// without a fresh, informed opt-in. Re-introduction requires a new decision.
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(fs.readFileSync(configPath(), "utf8"));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function writeConfig(cfg) {
|
|
66
|
+
const file = configPath();
|
|
67
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
68
|
+
fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n");
|
|
69
|
+
}
|
|
70
|
+
/** Detect CI / automation environments — never send from these. */
|
|
71
|
+
function isCI() {
|
|
72
|
+
const flags = ["CI", "GITHUB_ACTIONS", "GITLAB_CI", "CIRCLECI", "BUILDKITE",
|
|
73
|
+
"JENKINS_URL", "TF_BUILD", "DRONE", "TRAVIS", "APPVEYOR",
|
|
74
|
+
"BITBUCKET_BUILD_NUMBER", "TEAMCITY_VERSION", "CODEBUILD_BUILD_ID"];
|
|
75
|
+
return flags.some(f => process.env[f] != null && process.env[f] !== "");
|
|
76
|
+
}
|
|
77
|
+
/** Compute anon_id deterministically per machine, never reversible. */
|
|
78
|
+
export function computeAnonId() {
|
|
79
|
+
const seed = `great_cto/${os.userInfo().username || "?"}/${os.hostname() || "?"}`;
|
|
80
|
+
return crypto.createHash("sha256").update(seed).digest("hex").slice(0, 8);
|
|
81
|
+
}
|
|
82
|
+
/** Resolve telemetry-enabled state. Pure function, no side effects. */
|
|
83
|
+
export function isTelemetryEnabled(cliFlag = false) {
|
|
84
|
+
// Opt-out wins, in priority order:
|
|
85
|
+
if (process.env.DO_NOT_TRACK === "1" || process.env.DO_NOT_TRACK === "true")
|
|
86
|
+
return false;
|
|
87
|
+
if (process.env.GREAT_CTO_TELEMETRY === "off")
|
|
88
|
+
return false;
|
|
89
|
+
if (process.env.GREAT_CTO_DISABLE_TELEMETRY === "1")
|
|
90
|
+
return false;
|
|
91
|
+
if (process.env.GREATCTO_NO_TELEMETRY === "1")
|
|
92
|
+
return false;
|
|
93
|
+
if (cliFlag)
|
|
94
|
+
return false;
|
|
95
|
+
if (isCI())
|
|
96
|
+
return false;
|
|
97
|
+
// Opt-in checks (explicit, fresh decision only):
|
|
98
|
+
if (process.env.GREAT_CTO_TELEMETRY === "on")
|
|
99
|
+
return true;
|
|
100
|
+
const cfg = readConfig();
|
|
101
|
+
if (cfg.enabled === true)
|
|
102
|
+
return true;
|
|
103
|
+
// Default: opt-out.
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
function sanitize(opts) {
|
|
107
|
+
const command = opts.command.toLowerCase();
|
|
108
|
+
if (!ALLOWED_COMMANDS.has(command))
|
|
109
|
+
return null;
|
|
110
|
+
const archetypeRaw = (opts.archetype || "none").toLowerCase().trim();
|
|
111
|
+
const archetype = ALLOWED_ARCHETYPES.has(archetypeRaw) ? archetypeRaw : "unknown";
|
|
112
|
+
return {
|
|
113
|
+
ts: new Date().toISOString(),
|
|
114
|
+
version: opts.cliVersion,
|
|
115
|
+
command,
|
|
116
|
+
archetype,
|
|
117
|
+
node: process.version.replace(/^v/, ""),
|
|
118
|
+
os: process.platform,
|
|
119
|
+
exit_code: typeof opts.exitCode === "number" ? opts.exitCode : 0,
|
|
120
|
+
duration_ms: typeof opts.durationMs === "number" ? Math.max(0, Math.round(opts.durationMs)) : 0,
|
|
121
|
+
anon_id: computeAnonId(),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** Fire-and-forget POST. Never blocks. Never throws. Never logs unless DRYRUN. */
|
|
125
|
+
async function send(evt) {
|
|
126
|
+
if (process.env.GREAT_CTO_TELEMETRY_DRYRUN === "1") {
|
|
127
|
+
process.stderr.write(`[telemetry] would-send: ${JSON.stringify(evt)}\n`);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const ctrl = new AbortController();
|
|
132
|
+
const timer = setTimeout(() => ctrl.abort(), TELEMETRY_TIMEOUT_MS);
|
|
133
|
+
await fetch(TELEMETRY_ENDPOINT, {
|
|
134
|
+
method: "POST",
|
|
135
|
+
headers: { "Content-Type": "application/json" },
|
|
136
|
+
body: JSON.stringify(evt),
|
|
137
|
+
signal: ctrl.signal,
|
|
138
|
+
}).catch(() => { });
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
}
|
|
141
|
+
catch { /* best-effort */ }
|
|
142
|
+
}
|
|
143
|
+
// --- Public API ------------------------------------------------------------
|
|
144
|
+
/** First-run/install ping. Sent only when enabled. Idempotent across runs. */
|
|
145
|
+
export async function sendInstallPing(opts) {
|
|
146
|
+
if (!opts.consent)
|
|
147
|
+
return;
|
|
148
|
+
if (!isTelemetryEnabled())
|
|
149
|
+
return;
|
|
150
|
+
const evt = sanitize({ cliVersion: opts.cliVersion, command: "init", archetype: opts.archetype });
|
|
151
|
+
if (!evt)
|
|
152
|
+
return;
|
|
153
|
+
await send(evt);
|
|
154
|
+
}
|
|
155
|
+
/** Per-command usage ping. Sent only when enabled. Fire-and-forget. */
|
|
156
|
+
export async function sendUsagePing(opts) {
|
|
157
|
+
if (!isTelemetryEnabled())
|
|
158
|
+
return;
|
|
159
|
+
const evt = sanitize({
|
|
160
|
+
cliVersion: opts.cliVersion,
|
|
161
|
+
command: opts.subcommand,
|
|
162
|
+
archetype: opts.archetype,
|
|
163
|
+
exitCode: opts.exitCode,
|
|
164
|
+
durationMs: opts.durationMs,
|
|
165
|
+
});
|
|
166
|
+
if (!evt)
|
|
167
|
+
return;
|
|
168
|
+
await send(evt);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Legacy shim — preserved for backwards compatibility with callers in main.ts
|
|
172
|
+
* that pass `--no-telemetry`. With opt-IN default, consent resolution is
|
|
173
|
+
* trivial: enabled iff isTelemetryEnabled() returns true.
|
|
174
|
+
*/
|
|
175
|
+
export function resolveTelemetryConsent(cliFlag) {
|
|
176
|
+
return isTelemetryEnabled(cliFlag);
|
|
177
|
+
}
|
|
178
|
+
// --- `npx great-cto telemetry <on|off|status|whoami>` subcommand -----------
|
|
179
|
+
export function telemetrySubcommand(arg) {
|
|
180
|
+
const action = (arg || "status").toLowerCase();
|
|
181
|
+
switch (action) {
|
|
182
|
+
case "on": {
|
|
183
|
+
const cfg = readConfig();
|
|
184
|
+
cfg.enabled = true;
|
|
185
|
+
writeConfig(cfg);
|
|
186
|
+
return { exitCode: 0, output: `✓ telemetry enabled (config: ${configPath()})\n` +
|
|
187
|
+
` Anonymous events go to ${TELEMETRY_ENDPOINT}\n` +
|
|
188
|
+
` See docs/PRIVACY.md for the full data schema.\n` };
|
|
189
|
+
}
|
|
190
|
+
case "off": {
|
|
191
|
+
const cfg = readConfig();
|
|
192
|
+
cfg.enabled = false;
|
|
193
|
+
writeConfig(cfg);
|
|
194
|
+
return { exitCode: 0, output: `✓ telemetry disabled (config: ${configPath()})\n` };
|
|
195
|
+
}
|
|
196
|
+
case "status": {
|
|
197
|
+
const enabled = isTelemetryEnabled();
|
|
198
|
+
const reason = enabled
|
|
199
|
+
? "enabled (sending events to " + TELEMETRY_ENDPOINT + ")"
|
|
200
|
+
: isCI()
|
|
201
|
+
? "disabled (CI environment detected)"
|
|
202
|
+
: process.env.DO_NOT_TRACK === "1"
|
|
203
|
+
? "disabled (DO_NOT_TRACK=1)"
|
|
204
|
+
: "disabled (default; run 'great-cto telemetry on' to enable)";
|
|
205
|
+
return { exitCode: 0, output: `telemetry: ${reason}\n` +
|
|
206
|
+
`anon_id : ${computeAnonId()}\n` +
|
|
207
|
+
`endpoint : ${TELEMETRY_ENDPOINT}\n` +
|
|
208
|
+
`config : ${configPath()}\n` };
|
|
209
|
+
}
|
|
210
|
+
case "whoami": {
|
|
211
|
+
return { exitCode: 0, output: computeAnonId() + "\n" };
|
|
212
|
+
}
|
|
213
|
+
default: {
|
|
214
|
+
return { exitCode: 2, output: `usage: great-cto telemetry <on|off|status|whoami>\n` };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
package/package.json
CHANGED