great-cto 2.9.1 → 2.9.4
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/leash.js +289 -0
- package/dist/main.js +94 -104
- package/package.json +4 -2
- package/postinstall.mjs +86 -0
- package/dist/telemetry.js +0 -225
package/dist/leash.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// `great-cto leash <subcommand>` — install, start, status, kill, update.
|
|
2
|
+
//
|
|
3
|
+
// Distribution model: we track llm-leash by *git repository* (not PyPI) so
|
|
4
|
+
// every push to https://github.com/avelikiy/llm-leash main is one
|
|
5
|
+
// `great-cto leash update` away. The repo is cloned to ~/.great_cto/llm-leash
|
|
6
|
+
// and installed as editable (`pip install -e .`). Updates run `git pull` +
|
|
7
|
+
// `pip install -e . --upgrade`.
|
|
8
|
+
//
|
|
9
|
+
// Three sources of truth:
|
|
10
|
+
// 1. Installed SHA = git rev-parse HEAD in ~/.great_cto/llm-leash
|
|
11
|
+
// 2. Latest SHA = GitHub commits API
|
|
12
|
+
// 3. Pinned SHA = .great_cto/leash.json → "pinned_sha" (optional)
|
|
13
|
+
//
|
|
14
|
+
// If pinned_sha is set, update() refuses to bump past it without --force.
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { log, success, warn, error, cyan, dim, bold } from "./ui.js";
|
|
20
|
+
const REPO_URL = "https://github.com/avelikiy/llm-leash.git";
|
|
21
|
+
const REPO_API = "https://api.github.com/repos/avelikiy/llm-leash";
|
|
22
|
+
const INSTALL_ROOT = join(homedir(), ".great_cto", "llm-leash");
|
|
23
|
+
const CONFIG_PATH = join(homedir(), ".great_cto", "leash.json");
|
|
24
|
+
// ── public API ────────────────────────────────────────────────────────────────
|
|
25
|
+
export async function runLeash(argv) {
|
|
26
|
+
const sub = argv[0];
|
|
27
|
+
switch (sub) {
|
|
28
|
+
case undefined:
|
|
29
|
+
case "help":
|
|
30
|
+
case "--help":
|
|
31
|
+
case "-h":
|
|
32
|
+
printHelp();
|
|
33
|
+
return { exitCode: 0 };
|
|
34
|
+
case "install":
|
|
35
|
+
return install();
|
|
36
|
+
case "update":
|
|
37
|
+
return update(argv.includes("--force"));
|
|
38
|
+
case "status":
|
|
39
|
+
return status();
|
|
40
|
+
case "start":
|
|
41
|
+
return startProxy(argv.slice(1));
|
|
42
|
+
case "kill":
|
|
43
|
+
return killAll();
|
|
44
|
+
case "uninstall":
|
|
45
|
+
return uninstall();
|
|
46
|
+
default:
|
|
47
|
+
error(`great-cto leash: unknown subcommand '${sub}'`);
|
|
48
|
+
printHelp();
|
|
49
|
+
return { exitCode: 2 };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// ── subcommands ───────────────────────────────────────────────────────────────
|
|
53
|
+
function printHelp() {
|
|
54
|
+
log(bold("great-cto leash") + " — runtime governance for LLM agents (https://github.com/avelikiy/llm-leash)");
|
|
55
|
+
log("");
|
|
56
|
+
log(" " + cyan("install") + " clone the repo, install as editable, write default config");
|
|
57
|
+
log(" " + cyan("update") + " git pull + reinstall (auto-pulls latest commits from main)");
|
|
58
|
+
log(" " + cyan("status") + " installed version vs GitHub latest, last audit-log entry");
|
|
59
|
+
log(" " + cyan("start") + " start the HTTP proxy on :8765 (env-var deployment)");
|
|
60
|
+
log(" " + cyan("kill") + " fire kill switch — stops all in-flight LLM calls (<300 ms)");
|
|
61
|
+
log(" " + cyan("uninstall") + " remove ~/.great_cto/llm-leash (config left intact)");
|
|
62
|
+
log("");
|
|
63
|
+
log(dim(" Config: " + CONFIG_PATH));
|
|
64
|
+
log(dim(" Install dir: " + INSTALL_ROOT));
|
|
65
|
+
}
|
|
66
|
+
function install() {
|
|
67
|
+
if (!hasGit()) {
|
|
68
|
+
error("git is required. Install git first: https://git-scm.com/downloads");
|
|
69
|
+
return { exitCode: 1 };
|
|
70
|
+
}
|
|
71
|
+
if (!hasPython()) {
|
|
72
|
+
error("python3 is required. Install Python 3.10+ first: https://www.python.org/downloads/");
|
|
73
|
+
return { exitCode: 1 };
|
|
74
|
+
}
|
|
75
|
+
mkdirSync(join(homedir(), ".great_cto"), { recursive: true });
|
|
76
|
+
if (existsSync(INSTALL_ROOT)) {
|
|
77
|
+
warn(`llm-leash already cloned at ${INSTALL_ROOT}.`);
|
|
78
|
+
log(` Run ${cyan("great-cto leash update")} to pull latest.`);
|
|
79
|
+
return { exitCode: 0 };
|
|
80
|
+
}
|
|
81
|
+
log(dim(` cloning ${REPO_URL} → ${INSTALL_ROOT}`));
|
|
82
|
+
const cloneResult = spawnSync("git", ["clone", REPO_URL, INSTALL_ROOT], {
|
|
83
|
+
stdio: ["ignore", "pipe", "pipe"], timeout: 120_000,
|
|
84
|
+
});
|
|
85
|
+
if (cloneResult.status !== 0) {
|
|
86
|
+
error(`git clone failed: ${cloneResult.stderr?.toString() || "unknown"}`);
|
|
87
|
+
return { exitCode: 1 };
|
|
88
|
+
}
|
|
89
|
+
log(dim(` pip install -e .`));
|
|
90
|
+
const pipResult = spawnSync(pythonCmd(), ["-m", "pip", "install", "-e", INSTALL_ROOT, "--quiet"], {
|
|
91
|
+
stdio: ["ignore", "pipe", "pipe"], timeout: 240_000,
|
|
92
|
+
});
|
|
93
|
+
if (pipResult.status !== 0) {
|
|
94
|
+
warn("pip install reported errors — leash CLI may not be on PATH yet:");
|
|
95
|
+
warn(pipResult.stderr?.toString() || "");
|
|
96
|
+
log(` Try: ${cyan(`${pythonCmd()} -m pip install -e ${INSTALL_ROOT}`)}`);
|
|
97
|
+
}
|
|
98
|
+
// Default config (only if absent — never clobber user changes)
|
|
99
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
100
|
+
const defaults = {
|
|
101
|
+
enabled: true,
|
|
102
|
+
install_root: INSTALL_ROOT,
|
|
103
|
+
audit_path: join(homedir(), ".leash", "audit.jsonl"),
|
|
104
|
+
proxy_url: "http://localhost:8765",
|
|
105
|
+
daily_cap_usd: 50,
|
|
106
|
+
monthly_cap_usd: 500,
|
|
107
|
+
};
|
|
108
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(defaults, null, 2));
|
|
109
|
+
success(`wrote ${CONFIG_PATH}`);
|
|
110
|
+
}
|
|
111
|
+
const sha = getInstalledSha();
|
|
112
|
+
success(`llm-leash installed at ${INSTALL_ROOT}${sha ? ` (HEAD: ${sha})` : ""}`);
|
|
113
|
+
log("");
|
|
114
|
+
log("Next: " + cyan("great-cto leash status") + " — verify proxy reachable");
|
|
115
|
+
log(" " + cyan("great-cto leash start") + " — start HTTP proxy on :8765");
|
|
116
|
+
return { exitCode: 0 };
|
|
117
|
+
}
|
|
118
|
+
function update(force) {
|
|
119
|
+
if (!existsSync(INSTALL_ROOT)) {
|
|
120
|
+
warn("llm-leash not installed. Run `great-cto leash install` first.");
|
|
121
|
+
return { exitCode: 1 };
|
|
122
|
+
}
|
|
123
|
+
const cfg = readConfig();
|
|
124
|
+
const beforeSha = getInstalledSha();
|
|
125
|
+
if (cfg.pinned_sha && !force) {
|
|
126
|
+
log(dim(` pinned to ${cfg.pinned_sha} in ${CONFIG_PATH} — checkout pinned commit`));
|
|
127
|
+
const co = spawnSync("git", ["-C", INSTALL_ROOT, "fetch", "--quiet"], { stdio: "ignore", timeout: 60_000 });
|
|
128
|
+
if (co.status !== 0) {
|
|
129
|
+
error("git fetch failed");
|
|
130
|
+
return { exitCode: 1 };
|
|
131
|
+
}
|
|
132
|
+
const reset = spawnSync("git", ["-C", INSTALL_ROOT, "reset", "--hard", cfg.pinned_sha], {
|
|
133
|
+
stdio: ["ignore", "pipe", "pipe"], timeout: 30_000,
|
|
134
|
+
});
|
|
135
|
+
if (reset.status !== 0) {
|
|
136
|
+
error(`reset to pinned ${cfg.pinned_sha} failed: ${reset.stderr?.toString()}`);
|
|
137
|
+
return { exitCode: 1 };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
log(dim(` git pull origin main`));
|
|
142
|
+
const pull = spawnSync("git", ["-C", INSTALL_ROOT, "pull", "--ff-only", "origin", "main"], {
|
|
143
|
+
stdio: ["ignore", "pipe", "pipe"], timeout: 60_000,
|
|
144
|
+
});
|
|
145
|
+
if (pull.status !== 0) {
|
|
146
|
+
error(`git pull failed: ${pull.stderr?.toString()}`);
|
|
147
|
+
log(` Try: ${cyan(`cd ${INSTALL_ROOT} && git status`)}`);
|
|
148
|
+
return { exitCode: 1 };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const afterSha = getInstalledSha();
|
|
152
|
+
if (beforeSha === afterSha) {
|
|
153
|
+
log(dim(` already at latest (${afterSha})`));
|
|
154
|
+
return { exitCode: 0 };
|
|
155
|
+
}
|
|
156
|
+
log(dim(` pip install -e . --upgrade`));
|
|
157
|
+
const pip = spawnSync(pythonCmd(), ["-m", "pip", "install", "-e", INSTALL_ROOT, "--upgrade", "--quiet"], {
|
|
158
|
+
stdio: ["ignore", "pipe", "pipe"], timeout: 240_000,
|
|
159
|
+
});
|
|
160
|
+
if (pip.status !== 0) {
|
|
161
|
+
warn("pip reinstall reported errors:");
|
|
162
|
+
warn(pip.stderr?.toString() || "");
|
|
163
|
+
}
|
|
164
|
+
success(`llm-leash: ${beforeSha} → ${afterSha}`);
|
|
165
|
+
// Persist last-known SHA for the version-check hook
|
|
166
|
+
if (afterSha)
|
|
167
|
+
writeVersionCache(afterSha);
|
|
168
|
+
return { exitCode: 0 };
|
|
169
|
+
}
|
|
170
|
+
async function status() {
|
|
171
|
+
const installed = existsSync(INSTALL_ROOT);
|
|
172
|
+
if (!installed) {
|
|
173
|
+
log(bold("llm-leash:") + " " + dim("not installed"));
|
|
174
|
+
log(` Install: ${cyan("great-cto leash install")}`);
|
|
175
|
+
return { exitCode: 0 };
|
|
176
|
+
}
|
|
177
|
+
const cfg = readConfig();
|
|
178
|
+
const head = getInstalledSha() || "?";
|
|
179
|
+
const latest = await fetchLatestSha();
|
|
180
|
+
log(bold("llm-leash:") + " installed at " + dim(INSTALL_ROOT));
|
|
181
|
+
log(` Installed HEAD : ${head}`);
|
|
182
|
+
log(` GitHub latest : ${latest || dim("unknown (network?)")}`);
|
|
183
|
+
log(` Config : ${CONFIG_PATH}`);
|
|
184
|
+
log(` Audit log : ${cfg.audit_path || dim("default")}`);
|
|
185
|
+
log(` Daily cap : ${cfg.daily_cap_usd ? "$" + cfg.daily_cap_usd : dim("not set")}`);
|
|
186
|
+
log(` Pinned SHA : ${cfg.pinned_sha || dim("none — track main")}`);
|
|
187
|
+
if (latest && latest !== head) {
|
|
188
|
+
log("");
|
|
189
|
+
warn(`Update available. Run ${cyan("great-cto leash update")} to bump.`);
|
|
190
|
+
}
|
|
191
|
+
return { exitCode: 0 };
|
|
192
|
+
}
|
|
193
|
+
function startProxy(extraArgs) {
|
|
194
|
+
if (!existsSync(INSTALL_ROOT)) {
|
|
195
|
+
warn("llm-leash not installed. Run `great-cto leash install` first.");
|
|
196
|
+
return { exitCode: 1 };
|
|
197
|
+
}
|
|
198
|
+
log(`Starting llm-leash proxy on http://localhost:8765 …`);
|
|
199
|
+
log(dim(` set ANTHROPIC_BASE_URL=http://localhost:8765 to route via leash`));
|
|
200
|
+
const r = spawnSync(pythonCmd(), ["-m", "leash.proxy", ...extraArgs], {
|
|
201
|
+
stdio: "inherit",
|
|
202
|
+
});
|
|
203
|
+
return { exitCode: r.status ?? 0 };
|
|
204
|
+
}
|
|
205
|
+
function killAll() {
|
|
206
|
+
const r = spawnSync("leash", ["kill", "--all", "--reason", "cli"], {
|
|
207
|
+
stdio: ["ignore", "pipe", "pipe"], timeout: 5000,
|
|
208
|
+
});
|
|
209
|
+
if (r.status === 0) {
|
|
210
|
+
success("kill switch fired");
|
|
211
|
+
return { exitCode: 0 };
|
|
212
|
+
}
|
|
213
|
+
// Fall back to python -m
|
|
214
|
+
const r2 = spawnSync(pythonCmd(), ["-m", "leash", "kill", "--all", "--reason", "cli"], {
|
|
215
|
+
stdio: "inherit", timeout: 5000,
|
|
216
|
+
});
|
|
217
|
+
return { exitCode: r2.status ?? 1 };
|
|
218
|
+
}
|
|
219
|
+
function uninstall() {
|
|
220
|
+
if (!existsSync(INSTALL_ROOT)) {
|
|
221
|
+
log(dim("nothing to remove"));
|
|
222
|
+
return { exitCode: 0 };
|
|
223
|
+
}
|
|
224
|
+
const r = spawnSync("rm", ["-rf", INSTALL_ROOT], { stdio: "inherit", timeout: 30_000 });
|
|
225
|
+
if (r.status === 0)
|
|
226
|
+
success(`removed ${INSTALL_ROOT}`);
|
|
227
|
+
log(dim(`config left intact at ${CONFIG_PATH}`));
|
|
228
|
+
return { exitCode: r.status ?? 0 };
|
|
229
|
+
}
|
|
230
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
231
|
+
function hasGit() {
|
|
232
|
+
return spawnSync("git", ["--version"], { stdio: "ignore", timeout: 3000 }).status === 0;
|
|
233
|
+
}
|
|
234
|
+
function hasPython() {
|
|
235
|
+
return spawnSync(pythonCmd(), ["--version"], { stdio: "ignore", timeout: 3000 }).status === 0;
|
|
236
|
+
}
|
|
237
|
+
function pythonCmd() {
|
|
238
|
+
// Prefer python3, fall back to python
|
|
239
|
+
if (spawnSync("python3", ["--version"], { stdio: "ignore", timeout: 2000 }).status === 0)
|
|
240
|
+
return "python3";
|
|
241
|
+
return "python";
|
|
242
|
+
}
|
|
243
|
+
function getInstalledSha() {
|
|
244
|
+
if (!existsSync(INSTALL_ROOT))
|
|
245
|
+
return null;
|
|
246
|
+
const r = spawnSync("git", ["-C", INSTALL_ROOT, "rev-parse", "--short", "HEAD"], {
|
|
247
|
+
stdio: ["ignore", "pipe", "ignore"], timeout: 3000,
|
|
248
|
+
});
|
|
249
|
+
if (r.status !== 0)
|
|
250
|
+
return null;
|
|
251
|
+
return r.stdout.toString().trim();
|
|
252
|
+
}
|
|
253
|
+
async function fetchLatestSha() {
|
|
254
|
+
try {
|
|
255
|
+
const res = await fetch(`${REPO_API}/commits/main`, {
|
|
256
|
+
headers: {
|
|
257
|
+
"Accept": "application/vnd.github+json",
|
|
258
|
+
"User-Agent": "great-cto-leash",
|
|
259
|
+
},
|
|
260
|
+
// Node 20 has native AbortController + fetch; cap at 6 s.
|
|
261
|
+
signal: AbortSignal.timeout(6000),
|
|
262
|
+
});
|
|
263
|
+
if (!res.ok)
|
|
264
|
+
return null;
|
|
265
|
+
const j = await res.json();
|
|
266
|
+
return j.sha?.slice(0, 7) || null;
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function readConfig() {
|
|
273
|
+
try {
|
|
274
|
+
if (existsSync(CONFIG_PATH))
|
|
275
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
276
|
+
}
|
|
277
|
+
catch { /* ignore */ }
|
|
278
|
+
return {};
|
|
279
|
+
}
|
|
280
|
+
function writeVersionCache(sha) {
|
|
281
|
+
const cache = join(homedir(), ".great_cto", "leash-version.json");
|
|
282
|
+
try {
|
|
283
|
+
writeFileSync(cache, JSON.stringify({
|
|
284
|
+
installed_sha: sha,
|
|
285
|
+
last_checked: new Date().toISOString(),
|
|
286
|
+
}, null, 2));
|
|
287
|
+
}
|
|
288
|
+
catch { /* best-effort */ }
|
|
289
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -10,15 +10,14 @@
|
|
|
10
10
|
// 7. bootstrap .great_cto/PROJECT.md
|
|
11
11
|
// 8. print next steps
|
|
12
12
|
import { resolve } from "node:path";
|
|
13
|
-
import { banner, bold, cyan, dim, error,
|
|
13
|
+
import { banner, bold, cyan, dim, error, green, log, step, success, warn, yellow, confirm } from "./ui.js";
|
|
14
14
|
import { detect } from "./detect.js";
|
|
15
15
|
import { pickArchetype, suggestCompliance } from "./archetypes.js";
|
|
16
16
|
import { install, findInstalledVersions } from "./installer.js";
|
|
17
17
|
import { enableGreatCto } from "./settings.js";
|
|
18
18
|
import { bootstrap } from "./bootstrap.js";
|
|
19
19
|
import { shouldUseLlmFallback, suggestArchetypeFromLlm } from "./llm-fallback.js";
|
|
20
|
-
import {
|
|
21
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync as fsExistsSync } from "node:fs";
|
|
20
|
+
import { readFileSync, copyFileSync, chmodSync, existsSync as fsExistsSync } from "node:fs";
|
|
22
21
|
import { dirname, join } from "node:path";
|
|
23
22
|
import { fileURLToPath } from "node:url";
|
|
24
23
|
import { homedir } from "node:os";
|
|
@@ -96,8 +95,8 @@ function parseArgs(argv) {
|
|
|
96
95
|
args.command = "webhook";
|
|
97
96
|
else if (a === "report")
|
|
98
97
|
args.command = "report";
|
|
99
|
-
else if (a === "
|
|
100
|
-
args.command = "
|
|
98
|
+
else if (a === "leash")
|
|
99
|
+
args.command = "leash";
|
|
101
100
|
// Slash-commands surfaced as CLI subcommands so users get a clear hint
|
|
102
101
|
// instead of a confusing usage error. These work only in the chat plugin.
|
|
103
102
|
else if (a === "start" || a === "audit" || a === "inbox" || a === "digest" ||
|
|
@@ -114,8 +113,14 @@ function parseArgs(argv) {
|
|
|
114
113
|
args.dir = a.slice("--dir=".length);
|
|
115
114
|
else if (a === "--dir")
|
|
116
115
|
args.dir = argv[++i] ?? args.dir;
|
|
117
|
-
else if (a === "init" || a === "help" || a === "version") {
|
|
118
|
-
|
|
116
|
+
else if (a === "init" || a === "install" || a === "help" || a === "version") {
|
|
117
|
+
// `install` is an alias for `init`. Both run the same flow; only
|
|
118
|
+
// difference: `install` upgrades llm-leash to latest on every run,
|
|
119
|
+
// while `init` is silent-skip when already installed.
|
|
120
|
+
args.command = (a === "install" ? "init" : a);
|
|
121
|
+
if (a === "install") {
|
|
122
|
+
args._fromInstall = true;
|
|
123
|
+
}
|
|
119
124
|
}
|
|
120
125
|
else if (!a.startsWith("-") && args.command === "init" && i === 0) {
|
|
121
126
|
// First positional that isn't a recognised subcommand → unknown
|
|
@@ -349,7 +354,8 @@ function printHelp() {
|
|
|
349
354
|
log(`${bold("great-cto")} — one-command install for the great_cto Claude Code plugin
|
|
350
355
|
|
|
351
356
|
${bold("Usage:")}
|
|
352
|
-
npx great-cto
|
|
357
|
+
npx great-cto install [options] Same as init; also upgrades llm-leash
|
|
358
|
+
npx great-cto [init] [options] Detect + bootstrap; installs llm-leash if absent
|
|
353
359
|
npx great-cto board [--port 3141] [--no-open]
|
|
354
360
|
npx great-cto register [--dir PATH]
|
|
355
361
|
npx great-cto scan [path] [--severity LVL] [--scanner NAME] [--sarif FILE]
|
|
@@ -398,13 +404,6 @@ ${bold("Claude Code adapter:")}
|
|
|
398
404
|
great-cto adapt --dry-run Preview what would be written
|
|
399
405
|
${dim("Idempotent — re-run after editing .great_cto/PROJECT.md")}
|
|
400
406
|
|
|
401
|
-
${bold("Telemetry (opt-IN, off by default):")}
|
|
402
|
-
great-cto telemetry status Show current state + endpoint + anon_id
|
|
403
|
-
great-cto telemetry on Enable anonymous usage events
|
|
404
|
-
great-cto telemetry off Disable (also: DO_NOT_TRACK=1)
|
|
405
|
-
great-cto telemetry whoami Print your anon_id (8 hex chars)
|
|
406
|
-
${dim("Privacy: docs/PRIVACY.md · no code, no repo names, no PII")}
|
|
407
|
-
|
|
408
407
|
${bold("Webhook server (preview):")}
|
|
409
408
|
great-cto serve --port 3142 Webhook receiver (logs to ~/.great_cto/webhook-events.log)
|
|
410
409
|
${dim("Endpoints: POST /webhook/github, POST /webhook/generic, GET /events, GET /healthz")}
|
|
@@ -757,23 +756,18 @@ async function runInit(args) {
|
|
|
757
756
|
if (!bs.created) {
|
|
758
757
|
log(` ${dim("PROJECT.md already exists at")} ${bs.projectMdPath} ${dim("— kept as-is")}`);
|
|
759
758
|
}
|
|
759
|
+
// ── 6. install pre-push git hook ─────────────────────────
|
|
760
|
+
installPrePushHook(args.dir);
|
|
761
|
+
// ── 7. install / update llm-leash (runtime governance) ───
|
|
762
|
+
// `init` is idempotent (silent skip when present). `install` always
|
|
763
|
+
// upgrades to the latest commit on llm-leash main. Both best-effort:
|
|
764
|
+
// missing git/python doesn't fail the flow.
|
|
765
|
+
const fromInstall = args._fromInstall === true;
|
|
766
|
+
await tryInstallLeash(fromInstall);
|
|
760
767
|
// ── done ─────────────────────────────────────────────────
|
|
761
768
|
log("");
|
|
762
769
|
log(green(bold("✓ great_cto is ready.")));
|
|
763
770
|
log("");
|
|
764
|
-
// ── 6. opt-IN telemetry prompt ───────────────────────────
|
|
765
|
-
// Aggressive opt-IN promo (Option A from telemetry strategy).
|
|
766
|
-
// Shows up only when interactive + not CI + not already decided + DO_NOT_TRACK unset.
|
|
767
|
-
await promoteTelemetryOptIn({ archetype: archetype, cliVersion: getCliVersion(), yes: args.yes });
|
|
768
|
-
// If telemetry is enabled (either via prior opt-in or env var), fire a fresh
|
|
769
|
-
// install-ping so re-runs of `init` (after upgrades) count toward WAU/MAU.
|
|
770
|
-
if (isTelemetryEnabled()) {
|
|
771
|
-
await sendInstallPing({
|
|
772
|
-
cliVersion: getCliVersion(),
|
|
773
|
-
archetype: archetype,
|
|
774
|
-
consent: true,
|
|
775
|
-
}).catch(() => { });
|
|
776
|
-
}
|
|
777
771
|
log(bold("Next steps:"));
|
|
778
772
|
log(` 1. ${dim("Restart Claude Code to pick up the plugin.")}`);
|
|
779
773
|
log(` 2. ${dim("Edit")} ${cyan(".great_cto/PROJECT.md")} ${dim("to refine goals and compliance.")}`);
|
|
@@ -785,80 +779,69 @@ async function runInit(args) {
|
|
|
785
779
|
log("");
|
|
786
780
|
return 0;
|
|
787
781
|
}
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
return;
|
|
803
|
-
if (process.env.CI || process.env.GITHUB_ACTIONS)
|
|
804
|
-
return;
|
|
805
|
-
// If user already made a decision (either way), respect it — don't re-ask.
|
|
806
|
-
const cfgFile = join(homedir(), ".great_cto", "telemetry.json");
|
|
807
|
-
if (fsExistsSync(cfgFile)) {
|
|
808
|
-
try {
|
|
809
|
-
const cfg = JSON.parse(readFileSync(cfgFile, "utf8"));
|
|
810
|
-
if (cfg.enabled === true || cfg.enabled === false)
|
|
811
|
-
return;
|
|
782
|
+
/**
|
|
783
|
+
* Copy scripts/hooks/pre-push.sh from the installed plugin into the project's
|
|
784
|
+
* .git/hooks/pre-push so that future pushes are scanned for private project
|
|
785
|
+
* name leaks. Best-effort — never throws.
|
|
786
|
+
*/
|
|
787
|
+
function installPrePushHook(projectDir) {
|
|
788
|
+
try {
|
|
789
|
+
const gitHooksDir = join(projectDir, ".git", "hooks");
|
|
790
|
+
if (!fsExistsSync(gitHooksDir))
|
|
791
|
+
return; // not a git repo — skip silently
|
|
792
|
+
const dest = join(gitHooksDir, "pre-push");
|
|
793
|
+
if (fsExistsSync(dest)) {
|
|
794
|
+
log(` ${dim("pre-push hook already present — skipped")}`);
|
|
795
|
+
return;
|
|
812
796
|
}
|
|
813
|
-
|
|
797
|
+
// Locate source: dist/main.js → ../../scripts/hooks/pre-push.sh
|
|
798
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
799
|
+
const src = join(here, "..", "..", "scripts", "hooks", "pre-push.sh");
|
|
800
|
+
if (!fsExistsSync(src)) {
|
|
801
|
+
warn("pre-push hook source not found — skipping hook installation");
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
copyFileSync(src, dest);
|
|
805
|
+
chmodSync(dest, 0o755);
|
|
806
|
+
success("installed pre-push hook (blocks private project name leaks)");
|
|
814
807
|
}
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
log(bold("Help great_cto learn from how you use it?"));
|
|
818
|
-
log("");
|
|
819
|
-
log(dim("Anonymous usage data (default: off). Helps cross-project"));
|
|
820
|
-
log(dim("lessons promote to a global pattern library."));
|
|
821
|
-
log("");
|
|
822
|
-
log(dim("Here is exactly what would be sent — one event per command:"));
|
|
823
|
-
log("");
|
|
824
|
-
log(gray(` {`));
|
|
825
|
-
log(gray(` "version": "${opts.cliVersion}",`));
|
|
826
|
-
log(gray(` "command": "init",`));
|
|
827
|
-
log(gray(` "archetype": "${opts.archetype}",`));
|
|
828
|
-
log(gray(` "node": "${process.version.replace(/^v/, "")}",`));
|
|
829
|
-
log(gray(` "os": "${process.platform}",`));
|
|
830
|
-
log(gray(` "exit_code": 0,`));
|
|
831
|
-
log(gray(` "duration_ms": 1234,`));
|
|
832
|
-
log(gray(` "anon_id": "${computeAnonId()}" ${dim("// 8 hex chars, not reversible")}`));
|
|
833
|
-
log(gray(` }`));
|
|
834
|
-
log("");
|
|
835
|
-
log(dim("No code, no repo names, no file paths, no PII."));
|
|
836
|
-
log(dim("Toggle anytime: " + cyan("npx great-cto telemetry off")));
|
|
837
|
-
log(dim("Privacy: " + cyan("github.com/avelikiy/great_cto/blob/main/docs/PRIVACY.md")));
|
|
838
|
-
log("");
|
|
839
|
-
const yes = await confirm(bold("Enable anonymous telemetry?"), false);
|
|
840
|
-
log("");
|
|
841
|
-
// Persist the decision either way so we never re-ask.
|
|
842
|
-
try {
|
|
843
|
-
const dir = join(homedir(), ".great_cto");
|
|
844
|
-
mkdirSync(dir, { recursive: true });
|
|
845
|
-
writeFileSync(join(dir, "telemetry.json"), JSON.stringify({ enabled: yes, decided_at: new Date().toISOString() }, null, 2) + "\n");
|
|
808
|
+
catch {
|
|
809
|
+
// Best-effort: hook failure must never block init
|
|
846
810
|
}
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Best-effort llm-leash install — runs after bootstrap so every great-cto
|
|
814
|
+
* init turns on runtime governance for free.
|
|
815
|
+
*
|
|
816
|
+
* forceUpdate=false (called from `init`) — silent-skip if installed
|
|
817
|
+
* forceUpdate=true (called from `install`) — git pull + reinstall
|
|
818
|
+
*
|
|
819
|
+
* Never throws. Opt out via env: GREAT_CTO_SKIP_LEASH=1
|
|
820
|
+
*/
|
|
821
|
+
async function tryInstallLeash(forceUpdate = false) {
|
|
822
|
+
if (process.env.GREAT_CTO_SKIP_LEASH === "1") {
|
|
823
|
+
log(` ${dim("skipped llm-leash install (GREAT_CTO_SKIP_LEASH=1)")}`);
|
|
824
|
+
return;
|
|
858
825
|
}
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
826
|
+
try {
|
|
827
|
+
const { runLeash } = await import("./leash.js");
|
|
828
|
+
const { existsSync } = await import("node:fs");
|
|
829
|
+
const installRoot = join(homedir(), ".great_cto", "llm-leash");
|
|
830
|
+
if (existsSync(installRoot)) {
|
|
831
|
+
if (forceUpdate) {
|
|
832
|
+
log(` ${dim("updating llm-leash to latest …")}`);
|
|
833
|
+
await runLeash(["update"]);
|
|
834
|
+
}
|
|
835
|
+
else {
|
|
836
|
+
log(` ${dim("llm-leash already installed — skipped")}`);
|
|
837
|
+
}
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
log(` ${dim("installing llm-leash (runtime governance) …")}`);
|
|
841
|
+
await runLeash(["install"]);
|
|
842
|
+
}
|
|
843
|
+
catch {
|
|
844
|
+
warn("llm-leash install failed — run `great-cto leash install` manually later");
|
|
862
845
|
}
|
|
863
846
|
}
|
|
864
847
|
async function main() {
|
|
@@ -875,12 +858,6 @@ async function main() {
|
|
|
875
858
|
log(`Run ${cyan("great-cto --help")} for usage.`);
|
|
876
859
|
process.exit(2);
|
|
877
860
|
}
|
|
878
|
-
if (args.command === "telemetry") {
|
|
879
|
-
const sub = rawArgv[rawArgv.indexOf("telemetry") + 1];
|
|
880
|
-
const { exitCode, output } = telemetrySubcommand(sub);
|
|
881
|
-
process.stdout.write(output);
|
|
882
|
-
process.exit(exitCode);
|
|
883
|
-
}
|
|
884
861
|
if (args.command === "scan") {
|
|
885
862
|
try {
|
|
886
863
|
const code = await runScan(args, rawArgv);
|
|
@@ -992,6 +969,19 @@ async function main() {
|
|
|
992
969
|
process.exit(2);
|
|
993
970
|
}
|
|
994
971
|
}
|
|
972
|
+
if (args.command === "leash") {
|
|
973
|
+
try {
|
|
974
|
+
const { runLeash } = await import("./leash.js");
|
|
975
|
+
// rawArgv[0] is "leash" — pass the rest as subcommand + flags
|
|
976
|
+
const leashArgs = rawArgv.slice(rawArgv.indexOf("leash") + 1);
|
|
977
|
+
const result = await runLeash(leashArgs);
|
|
978
|
+
process.exit(result.exitCode);
|
|
979
|
+
}
|
|
980
|
+
catch (e) {
|
|
981
|
+
error(e.message);
|
|
982
|
+
process.exit(2);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
995
985
|
if (args.command === "chat-only-hint") {
|
|
996
986
|
const tried = args._slashTried || "<command>";
|
|
997
987
|
error(`'${tried}' is a chat slash command, not a CLI subcommand.`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "great-cto",
|
|
3
|
-
"version": "2.9.
|
|
3
|
+
"version": "2.9.4",
|
|
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",
|
|
@@ -70,6 +70,7 @@
|
|
|
70
70
|
"index.mjs",
|
|
71
71
|
"dist/",
|
|
72
72
|
"agentshield-rules/",
|
|
73
|
+
"postinstall.mjs",
|
|
73
74
|
"README.md"
|
|
74
75
|
],
|
|
75
76
|
"type": "module",
|
|
@@ -77,6 +78,7 @@
|
|
|
77
78
|
"build": "tsc",
|
|
78
79
|
"test": "npm run build && node --test tests/*.test.mjs tests/**/*.test.mjs",
|
|
79
80
|
"test:e2e": "npm run build && node ../../tests/run-archetype-e2e.mjs",
|
|
81
|
+
"postinstall": "node postinstall.mjs",
|
|
80
82
|
"prepublishOnly": "npm run build"
|
|
81
83
|
},
|
|
82
84
|
"devDependencies": {
|
|
@@ -86,4 +88,4 @@
|
|
|
86
88
|
"engines": {
|
|
87
89
|
"node": ">=18.17.0"
|
|
88
90
|
}
|
|
89
|
-
}
|
|
91
|
+
}
|
package/postinstall.mjs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* great-cto postinstall — best-effort one-time setup that runs when the npm
|
|
4
|
+
* package is installed globally or as a dependency.
|
|
5
|
+
*
|
|
6
|
+
* Currently does one thing: install llm-leash (https://github.com/avelikiy/llm-leash)
|
|
7
|
+
* for runtime governance — budget caps, audit log, kill switch, HITL gates.
|
|
8
|
+
*
|
|
9
|
+
* Design rules:
|
|
10
|
+
* - NEVER fail the npm install. All errors swallowed.
|
|
11
|
+
* - Idempotent — skips if ~/.great_cto/llm-leash already cloned.
|
|
12
|
+
* - Honors GREAT_CTO_SKIP_LEASH=1 to opt out (CI envs, restricted machines).
|
|
13
|
+
* - Skips on CI by default unless GREAT_CTO_FORCE_LEASH=1 — npm install in
|
|
14
|
+
* CI shouldn't trigger 30s of git clone + pip install per build.
|
|
15
|
+
* - Skips if `npm install` was invoked with --ignore-scripts (npm sets
|
|
16
|
+
* `npm_config_ignore_scripts=true` — actually no, it just doesn't run
|
|
17
|
+
* scripts; we can't detect it from inside).
|
|
18
|
+
* - Detached output — postinstall noise is intentional and short.
|
|
19
|
+
*
|
|
20
|
+
* The "real" install path remains `great-cto leash install`. This hook just
|
|
21
|
+
* makes the common case (one-shot `npm install -g great-cto`) feel zero-config.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync } from 'node:fs';
|
|
25
|
+
import { spawnSync } from 'node:child_process';
|
|
26
|
+
import { homedir } from 'node:os';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
|
|
29
|
+
const INSTALL_ROOT = path.join(homedir(), '.great_cto', 'llm-leash');
|
|
30
|
+
|
|
31
|
+
function main() {
|
|
32
|
+
// ── opt-outs ─────────────────────────────────────────────────────────────
|
|
33
|
+
if (process.env.GREAT_CTO_SKIP_LEASH === '1') {
|
|
34
|
+
return; // silent
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Skip in CI unless explicitly forced — CI builds get no benefit from
|
|
38
|
+
// having leash installed in the runner's home dir, and the latency hurts.
|
|
39
|
+
const inCI = process.env.CI === 'true' || process.env.CI === '1';
|
|
40
|
+
if (inCI && process.env.GREAT_CTO_FORCE_LEASH !== '1') {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Already installed — fast exit
|
|
45
|
+
if (existsSync(INSTALL_ROOT)) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Need git + python3 — bail silently if either is missing
|
|
50
|
+
if (!hasCommand('git') || !hasCommand('python3')) {
|
|
51
|
+
console.log('[great-cto] llm-leash skipped — git or python3 not on PATH');
|
|
52
|
+
console.log('[great-cto] run `great-cto leash install` later to enable runtime governance');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Locate the bundled dist/main.js — postinstall runs with cwd=package root
|
|
57
|
+
const here = path.dirname(new URL(import.meta.url).pathname);
|
|
58
|
+
const cli = path.join(here, 'dist', 'main.js');
|
|
59
|
+
if (!existsSync(cli)) {
|
|
60
|
+
return; // package built incorrectly — fail-safe
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log('[great-cto] installing llm-leash for runtime governance (~30s) …');
|
|
64
|
+
console.log('[great-cto] opt out next time: GREAT_CTO_SKIP_LEASH=1 npm install -g great-cto');
|
|
65
|
+
|
|
66
|
+
const result = spawnSync(process.execPath, [cli, 'leash', 'install'], {
|
|
67
|
+
stdio: 'inherit',
|
|
68
|
+
timeout: 300_000,
|
|
69
|
+
env: { ...process.env, NO_COLOR: process.env.NO_COLOR || '1' },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (result.status !== 0) {
|
|
73
|
+
console.log('[great-cto] llm-leash install hit an issue — run `great-cto leash install` later');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function hasCommand(cmd) {
|
|
78
|
+
try {
|
|
79
|
+
const r = spawnSync(cmd, ['--version'], { stdio: 'ignore', timeout: 3000 });
|
|
80
|
+
return r.status === 0;
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try { main(); } catch { /* never fail npm install */ }
|
package/dist/telemetry.js
DELETED
|
@@ -1,225 +0,0 @@
|
|
|
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", "scan", "ci", "list-rules", "board", "register",
|
|
37
|
-
"adapt", "mcp", "report", "serve", "webhook",
|
|
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 legacyConfigPath() {
|
|
54
|
-
return path.join(os.homedir(), ".great_cto", "config.json");
|
|
55
|
-
}
|
|
56
|
-
function readConfig() {
|
|
57
|
-
// Try new file first.
|
|
58
|
-
try {
|
|
59
|
-
return JSON.parse(fs.readFileSync(configPath(), "utf8"));
|
|
60
|
-
}
|
|
61
|
-
catch { /* fall through */ }
|
|
62
|
-
// Fall back to legacy config.json (read-only — never write to it).
|
|
63
|
-
try {
|
|
64
|
-
const legacy = JSON.parse(fs.readFileSync(legacyConfigPath(), "utf8"));
|
|
65
|
-
return { enabled: legacy.telemetry, install_id: legacy.install_id };
|
|
66
|
-
}
|
|
67
|
-
catch {
|
|
68
|
-
return {};
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
function writeConfig(cfg) {
|
|
72
|
-
const file = configPath();
|
|
73
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
74
|
-
fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n");
|
|
75
|
-
}
|
|
76
|
-
/** Detect CI / automation environments — never send from these. */
|
|
77
|
-
function isCI() {
|
|
78
|
-
const flags = ["CI", "GITHUB_ACTIONS", "GITLAB_CI", "CIRCLECI", "BUILDKITE",
|
|
79
|
-
"JENKINS_URL", "TF_BUILD", "DRONE", "TRAVIS", "APPVEYOR",
|
|
80
|
-
"BITBUCKET_BUILD_NUMBER", "TEAMCITY_VERSION", "CODEBUILD_BUILD_ID"];
|
|
81
|
-
return flags.some(f => process.env[f] != null && process.env[f] !== "");
|
|
82
|
-
}
|
|
83
|
-
/** Compute anon_id deterministically per machine, never reversible. */
|
|
84
|
-
export function computeAnonId() {
|
|
85
|
-
const seed = `great_cto/${os.userInfo().username || "?"}/${os.hostname() || "?"}`;
|
|
86
|
-
return crypto.createHash("sha256").update(seed).digest("hex").slice(0, 8);
|
|
87
|
-
}
|
|
88
|
-
/** Resolve telemetry-enabled state. Pure function, no side effects. */
|
|
89
|
-
export function isTelemetryEnabled(cliFlag = false) {
|
|
90
|
-
// Opt-out wins, in priority order:
|
|
91
|
-
if (process.env.DO_NOT_TRACK === "1" || process.env.DO_NOT_TRACK === "true")
|
|
92
|
-
return false;
|
|
93
|
-
if (process.env.GREAT_CTO_TELEMETRY === "off")
|
|
94
|
-
return false;
|
|
95
|
-
if (process.env.GREAT_CTO_DISABLE_TELEMETRY === "1")
|
|
96
|
-
return false;
|
|
97
|
-
if (process.env.GREATCTO_NO_TELEMETRY === "1")
|
|
98
|
-
return false;
|
|
99
|
-
if (cliFlag)
|
|
100
|
-
return false;
|
|
101
|
-
if (isCI())
|
|
102
|
-
return false;
|
|
103
|
-
// Opt-in checks:
|
|
104
|
-
if (process.env.GREAT_CTO_TELEMETRY === "on")
|
|
105
|
-
return true;
|
|
106
|
-
const cfg = readConfig();
|
|
107
|
-
if (cfg.enabled === true)
|
|
108
|
-
return true;
|
|
109
|
-
if (cfg.telemetry === true)
|
|
110
|
-
return true; // legacy
|
|
111
|
-
// Default: opt-out.
|
|
112
|
-
return false;
|
|
113
|
-
}
|
|
114
|
-
function sanitize(opts) {
|
|
115
|
-
const command = opts.command.toLowerCase();
|
|
116
|
-
if (!ALLOWED_COMMANDS.has(command))
|
|
117
|
-
return null;
|
|
118
|
-
const archetypeRaw = (opts.archetype || "none").toLowerCase().trim();
|
|
119
|
-
const archetype = ALLOWED_ARCHETYPES.has(archetypeRaw) ? archetypeRaw : "unknown";
|
|
120
|
-
return {
|
|
121
|
-
ts: new Date().toISOString(),
|
|
122
|
-
version: opts.cliVersion,
|
|
123
|
-
command,
|
|
124
|
-
archetype,
|
|
125
|
-
node: process.version.replace(/^v/, ""),
|
|
126
|
-
os: process.platform,
|
|
127
|
-
exit_code: typeof opts.exitCode === "number" ? opts.exitCode : 0,
|
|
128
|
-
duration_ms: typeof opts.durationMs === "number" ? Math.max(0, Math.round(opts.durationMs)) : 0,
|
|
129
|
-
anon_id: computeAnonId(),
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
/** Fire-and-forget POST. Never blocks. Never throws. Never logs unless DRYRUN. */
|
|
133
|
-
async function send(evt) {
|
|
134
|
-
if (process.env.GREAT_CTO_TELEMETRY_DRYRUN === "1") {
|
|
135
|
-
process.stderr.write(`[telemetry] would-send: ${JSON.stringify(evt)}\n`);
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
|
-
try {
|
|
139
|
-
const ctrl = new AbortController();
|
|
140
|
-
const timer = setTimeout(() => ctrl.abort(), TELEMETRY_TIMEOUT_MS);
|
|
141
|
-
await fetch(TELEMETRY_ENDPOINT, {
|
|
142
|
-
method: "POST",
|
|
143
|
-
headers: { "Content-Type": "application/json" },
|
|
144
|
-
body: JSON.stringify(evt),
|
|
145
|
-
signal: ctrl.signal,
|
|
146
|
-
}).catch(() => { });
|
|
147
|
-
clearTimeout(timer);
|
|
148
|
-
}
|
|
149
|
-
catch { /* best-effort */ }
|
|
150
|
-
}
|
|
151
|
-
// --- Public API ------------------------------------------------------------
|
|
152
|
-
/** First-run/install ping. Sent only when enabled. Idempotent across runs. */
|
|
153
|
-
export async function sendInstallPing(opts) {
|
|
154
|
-
if (!opts.consent)
|
|
155
|
-
return;
|
|
156
|
-
if (!isTelemetryEnabled())
|
|
157
|
-
return;
|
|
158
|
-
const evt = sanitize({ cliVersion: opts.cliVersion, command: "init", archetype: opts.archetype });
|
|
159
|
-
if (!evt)
|
|
160
|
-
return;
|
|
161
|
-
await send(evt);
|
|
162
|
-
}
|
|
163
|
-
/** Per-command usage ping. Sent only when enabled. Fire-and-forget. */
|
|
164
|
-
export async function sendUsagePing(opts) {
|
|
165
|
-
if (!isTelemetryEnabled())
|
|
166
|
-
return;
|
|
167
|
-
const evt = sanitize({
|
|
168
|
-
cliVersion: opts.cliVersion,
|
|
169
|
-
command: opts.subcommand,
|
|
170
|
-
archetype: opts.archetype,
|
|
171
|
-
exitCode: opts.exitCode,
|
|
172
|
-
durationMs: opts.durationMs,
|
|
173
|
-
});
|
|
174
|
-
if (!evt)
|
|
175
|
-
return;
|
|
176
|
-
await send(evt);
|
|
177
|
-
}
|
|
178
|
-
/**
|
|
179
|
-
* Legacy shim — preserved for backwards compatibility with callers in main.ts
|
|
180
|
-
* that pass `--no-telemetry`. With opt-IN default, consent resolution is
|
|
181
|
-
* trivial: enabled iff isTelemetryEnabled() returns true.
|
|
182
|
-
*/
|
|
183
|
-
export function resolveTelemetryConsent(cliFlag) {
|
|
184
|
-
return isTelemetryEnabled(cliFlag);
|
|
185
|
-
}
|
|
186
|
-
// --- `npx great-cto telemetry <on|off|status|whoami>` subcommand -----------
|
|
187
|
-
export function telemetrySubcommand(arg) {
|
|
188
|
-
const action = (arg || "status").toLowerCase();
|
|
189
|
-
switch (action) {
|
|
190
|
-
case "on": {
|
|
191
|
-
const cfg = readConfig();
|
|
192
|
-
cfg.enabled = true;
|
|
193
|
-
writeConfig(cfg);
|
|
194
|
-
return { exitCode: 0, output: `✓ telemetry enabled (config: ${configPath()})\n` +
|
|
195
|
-
` Anonymous events go to ${TELEMETRY_ENDPOINT}\n` +
|
|
196
|
-
` See docs/PRIVACY.md for the full data schema.\n` };
|
|
197
|
-
}
|
|
198
|
-
case "off": {
|
|
199
|
-
const cfg = readConfig();
|
|
200
|
-
cfg.enabled = false;
|
|
201
|
-
writeConfig(cfg);
|
|
202
|
-
return { exitCode: 0, output: `✓ telemetry disabled (config: ${configPath()})\n` };
|
|
203
|
-
}
|
|
204
|
-
case "status": {
|
|
205
|
-
const enabled = isTelemetryEnabled();
|
|
206
|
-
const reason = enabled
|
|
207
|
-
? "enabled (sending events to " + TELEMETRY_ENDPOINT + ")"
|
|
208
|
-
: isCI()
|
|
209
|
-
? "disabled (CI environment detected)"
|
|
210
|
-
: process.env.DO_NOT_TRACK === "1"
|
|
211
|
-
? "disabled (DO_NOT_TRACK=1)"
|
|
212
|
-
: "disabled (default; run 'great-cto telemetry on' to enable)";
|
|
213
|
-
return { exitCode: 0, output: `telemetry: ${reason}\n` +
|
|
214
|
-
`anon_id : ${computeAnonId()}\n` +
|
|
215
|
-
`endpoint : ${TELEMETRY_ENDPOINT}\n` +
|
|
216
|
-
`config : ${configPath()}\n` };
|
|
217
|
-
}
|
|
218
|
-
case "whoami": {
|
|
219
|
-
return { exitCode: 0, output: computeAnonId() + "\n" };
|
|
220
|
-
}
|
|
221
|
-
default: {
|
|
222
|
-
return { exitCode: 2, output: `usage: great-cto telemetry <on|off|status|whoami>\n` };
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
}
|