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 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
- const versions = readdirSync(pluginBase).filter(v => /^\d/.test(v)).sort().reverse();
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
- process.exit(0);
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
- process.exit(code);
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
- process.exit(code);
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
- process.exit(code);
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
- process.exit(code);
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
- process.exit(code);
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
- process.exit(code);
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
- process.exit(code);
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
- process.exit(code);
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
- process.exit(code);
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
- process.exit(code);
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
- process.exit(code);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "2.69.0",
3
+ "version": "2.70.1",
4
4
  "description": "One command install for the great_cto Claude Code plugin. Auto-detects your stack, picks the right archetype, bootstraps PROJECT.md.",
5
5
  "keywords": [
6
6
  "claude-code",