@yaroslavhaidash/gitstats-cli 0.2.0
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 +17 -0
- package/dist/cli.js +595 -0
- package/package.json +12 -0
package/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# gitstats-cli
|
|
2
|
+
|
|
3
|
+
Counts commits and lines in the git repos on your computer and sends **only the numbers** (per repo, per week) to your [gitstats](https://gitstats-three-zeta.vercel.app) profile. No file contents, no diffs, no GitHub tokens, no permissions on GitHub.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx --yes github:yaroslavhaidash/gitstats-cli link
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
That pairs this computer (you confirm in the browser), scans your home folder for repos, uploads the last year, and installs a daily background sync (launchd on macOS, Task Scheduler on Windows, systemd user timer on Linux). Nothing else to remember.
|
|
10
|
+
|
|
11
|
+
`link` shows you exactly what it found and asks before the first upload.
|
|
12
|
+
|
|
13
|
+
What leaves the machine, per repo: an HMAC-SHA256 of the normalised remote URL keyed with a per-user secret the server issued at pairing (so the same repo from two of your machines counts once, and public repos the server already knows are recognised without sending their name), a guessed main language, `{week, additions, deletions, commits}` and `{day, commits}` for your commits (`git log --no-merges --fixed-strings --author=<your emails>` on the default branch, exact email match, weeks bucketed Sunday 00:00 UTC). Repo names are **not** sent unless you run `gitstats names on`. Honest limit: the HMAC key lives on the server, so the operator could confirm a guess about a specific URL; he cannot enumerate your repos from the hashes.
|
|
14
|
+
|
|
15
|
+
You are trusting the operator: `npx github:…` runs the committed `dist/` from this repo. Read `src/cli.ts` (~400 lines) or watch the payload with a proxy.
|
|
16
|
+
|
|
17
|
+
Commands: `sync` · `status` · `add <path>` · `roots add <dir>` · `emails add <email>` · `names on|off` · `unlink` (also revokes server-side). Config lives in `~/.gitstats/config.json` (mode 600).
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* gitstats CLI — counts commits and lines in the git repos on this machine and sends ONLY the
|
|
4
|
+
* numbers (per repo, per week) to your gitstats profile. No file contents, no diffs, no GitHub tokens.
|
|
5
|
+
*
|
|
6
|
+
* npx --yes github:yaroslavhaidash/gitstats-cli link
|
|
7
|
+
* pair this computer, scan for repos, sync, install a daily sync
|
|
8
|
+
* gitstats sync fetch each repo's default branch, recount the last year, upload (idempotent; --no-fetch to skip)
|
|
9
|
+
* gitstats status show what is linked and when it last ran
|
|
10
|
+
* gitstats add <path> track a repo outside the scanned folders
|
|
11
|
+
* gitstats roots add <dir> scan another folder (e.g. one outside your home directory)
|
|
12
|
+
* gitstats emails add <e> attribute commits made with another email to you
|
|
13
|
+
* gitstats names on|off also send repo names (off by default; your own page then labels private repos by hash)
|
|
14
|
+
* gitstats pause | resume stop / restart the background sync without unlinking
|
|
15
|
+
* gitstats update fetch the latest published version and replace the installed copy
|
|
16
|
+
* gitstats unlink revoke this computer and remove the schedule and local config
|
|
17
|
+
*/
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
import { createHmac } from "node:crypto";
|
|
20
|
+
import { createInterface } from "node:readline/promises";
|
|
21
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync, cpSync } from "node:fs";
|
|
22
|
+
import { homedir, hostname, platform, tmpdir } from "node:os";
|
|
23
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
const DEFAULT_SERVER = "https://gitstats-three-zeta.vercel.app";
|
|
26
|
+
const PKG = "@yaroslavhaidash/gitstats-cli";
|
|
27
|
+
const HOME = homedir();
|
|
28
|
+
const DIR = join(HOME, ".gitstats");
|
|
29
|
+
const CONFIG = join(DIR, "config.json");
|
|
30
|
+
const SELF = join(DIR, "cli");
|
|
31
|
+
const DAYS = 365;
|
|
32
|
+
const SKIP_DIRS = new Set(["node_modules", "Library", "Applications", ".Trash", "vendor", "target", "build", "dist", ".venv", "venv", "__pycache__", "Pods", "DerivedData", "go", ".cargo", ".rustup", ".npm", ".cache", ".local", "snap", "AppData"]);
|
|
33
|
+
const args = process.argv.slice(2);
|
|
34
|
+
const cmd = args[0] ?? "help";
|
|
35
|
+
function log(msg) {
|
|
36
|
+
console.log(msg);
|
|
37
|
+
}
|
|
38
|
+
function loadConfig() {
|
|
39
|
+
if (!existsSync(CONFIG))
|
|
40
|
+
return null;
|
|
41
|
+
return JSON.parse(readFileSync(CONFIG, "utf8"));
|
|
42
|
+
}
|
|
43
|
+
function saveConfig(c) {
|
|
44
|
+
mkdirSync(DIR, { recursive: true });
|
|
45
|
+
writeFileSync(CONFIG, JSON.stringify(c, null, 2) + "\n", { mode: 0o600 });
|
|
46
|
+
}
|
|
47
|
+
function git(cwd, ...a) {
|
|
48
|
+
const r = spawnSync("git", a, { cwd, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
49
|
+
return r.status === 0 ? r.stdout : null;
|
|
50
|
+
}
|
|
51
|
+
function globalEmail() {
|
|
52
|
+
const r = spawnSync("git", ["config", "--global", "user.email"], { encoding: "utf8" });
|
|
53
|
+
return r.status === 0 ? r.stdout.trim() || null : null;
|
|
54
|
+
}
|
|
55
|
+
// ---------- repo discovery ----------
|
|
56
|
+
function isWorktree(repo) {
|
|
57
|
+
try {
|
|
58
|
+
return statSync(join(repo, ".git")).isFile();
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function findRepos(roots, explicit) {
|
|
65
|
+
const found = new Set(explicit.filter((p) => existsSync(join(p, ".git"))));
|
|
66
|
+
const walk = (dir, depth) => {
|
|
67
|
+
if (depth > 5)
|
|
68
|
+
return;
|
|
69
|
+
let entries;
|
|
70
|
+
try {
|
|
71
|
+
entries = readdirSync(dir);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (entries.includes(".git")) {
|
|
77
|
+
found.add(dir);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
for (const e of entries) {
|
|
81
|
+
if (SKIP_DIRS.has(e) || (e.startsWith(".") && depth > 0))
|
|
82
|
+
continue;
|
|
83
|
+
const p = join(dir, e);
|
|
84
|
+
try {
|
|
85
|
+
if (statSync(p).isDirectory())
|
|
86
|
+
walk(p, depth + 1);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
/* unreadable, skip */
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
for (const r of roots)
|
|
94
|
+
walk(r, 0);
|
|
95
|
+
// Real clones before worktrees, so the dedupe below keeps the primary checkout.
|
|
96
|
+
return [...found].sort((a, b) => Number(isWorktree(a)) - Number(isWorktree(b)) || a.localeCompare(b));
|
|
97
|
+
}
|
|
98
|
+
/** `remote:github.com/owner/name` (lower-cased, no protocol/user/.git) or `path:<dir>` when there is no remote. */
|
|
99
|
+
function remoteInfo(repo) {
|
|
100
|
+
const raw = git(repo, "config", "--get", "remote.origin.url")?.trim();
|
|
101
|
+
if (!raw)
|
|
102
|
+
return { key: `path:${repo}`, label: basename(repo) };
|
|
103
|
+
const norm = raw.replace(/^git@([^:]+):/, "$1/").replace(/^[a-z]+:\/\//, "").replace(/^[^@]+@/, "").replace(/\.git$/, "").replace(/\/$/, "").toLowerCase();
|
|
104
|
+
return { key: `remote:${norm}`, label: norm.split("/").slice(-2).join("/") || basename(repo) };
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Refresh origin's default branch so commits pushed from other machines are counted here too.
|
|
108
|
+
* Quiet, no credential prompts, bounded; a failure just means we count what is already local.
|
|
109
|
+
*/
|
|
110
|
+
function refresh(repo, ref) {
|
|
111
|
+
if (!ref.startsWith("origin/"))
|
|
112
|
+
return;
|
|
113
|
+
spawnSync("git", ["fetch", "-q", "origin", ref.slice("origin/".length)], {
|
|
114
|
+
cwd: repo,
|
|
115
|
+
stdio: "ignore",
|
|
116
|
+
timeout: 20_000,
|
|
117
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_SSH_COMMAND: "ssh -o BatchMode=yes" },
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
function defaultRef(repo) {
|
|
121
|
+
const head = git(repo, "symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD")?.trim();
|
|
122
|
+
if (head)
|
|
123
|
+
return head;
|
|
124
|
+
for (const ref of ["origin/main", "origin/master", "main", "master"]) {
|
|
125
|
+
if (git(repo, "rev-parse", "--verify", "-q", ref) !== null)
|
|
126
|
+
return ref;
|
|
127
|
+
}
|
|
128
|
+
return "HEAD";
|
|
129
|
+
}
|
|
130
|
+
// ---------- counting ----------
|
|
131
|
+
const LANG = {
|
|
132
|
+
ts: "TypeScript", tsx: "TypeScript", js: "JavaScript", jsx: "JavaScript", mjs: "JavaScript", cjs: "JavaScript",
|
|
133
|
+
py: "Python", rb: "Ruby", go: "Go", rs: "Rust", java: "Java", kt: "Kotlin", swift: "Swift", cs: "C#", cpp: "C++", cc: "C++", c: "C", h: "C",
|
|
134
|
+
php: "PHP", html: "HTML", css: "CSS", scss: "SCSS", vue: "Vue", svelte: "Svelte", dart: "Dart", scala: "Scala", ex: "Elixir", exs: "Elixir",
|
|
135
|
+
sql: "SQL", sh: "Shell", zsh: "Shell", lua: "Lua", r: "R", m: "Objective-C", hs: "Haskell", clj: "Clojure", elm: "Elm", tf: "HCL",
|
|
136
|
+
};
|
|
137
|
+
const NOISE = new Set(["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "Cargo.lock", "poetry.lock", "Gemfile.lock", "composer.lock", "go.sum"]);
|
|
138
|
+
/** Sunday 00:00 UTC of the week containing `d`, as YYYY-MM-DD — same bucketing as GitHub's stats/contributors. */
|
|
139
|
+
function weekStartUtc(d) {
|
|
140
|
+
const s = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - d.getUTCDay()));
|
|
141
|
+
return s.toISOString().slice(0, 10);
|
|
142
|
+
}
|
|
143
|
+
/** Read one `git log --numstat` run into week and day buckets. Same rules for merged and pending work. */
|
|
144
|
+
function tally(out, all) {
|
|
145
|
+
const weeks = new Map();
|
|
146
|
+
const days = new Map();
|
|
147
|
+
const langLines = new Map();
|
|
148
|
+
for (const rec of out.split("\x1e").slice(1)) {
|
|
149
|
+
const [header, ...lines] = rec.split("\n");
|
|
150
|
+
const [, dateStr, authorEmail] = header?.split("\x1f") ?? [];
|
|
151
|
+
// --author is a substring match; keep only exact email matches.
|
|
152
|
+
if (!dateStr || !authorEmail || !all.some((e) => e.toLowerCase() === authorEmail.toLowerCase()))
|
|
153
|
+
continue;
|
|
154
|
+
const when = new Date(dateStr);
|
|
155
|
+
const ws = weekStartUtc(when);
|
|
156
|
+
const w = weeks.get(ws) ?? { weekStart: ws, additions: 0, deletions: 0, commits: 0 };
|
|
157
|
+
w.commits += 1;
|
|
158
|
+
const date = when.toISOString().slice(0, 10);
|
|
159
|
+
const day = days.get(date) ?? { date, additions: 0, deletions: 0, commits: 0 };
|
|
160
|
+
day.commits += 1;
|
|
161
|
+
for (const l of lines) {
|
|
162
|
+
const [a, d, path] = l.split("\t");
|
|
163
|
+
if (!a || !d || !path || a === "-" || d === "-")
|
|
164
|
+
continue;
|
|
165
|
+
w.additions += Number(a);
|
|
166
|
+
w.deletions += Number(d);
|
|
167
|
+
day.additions += Number(a);
|
|
168
|
+
day.deletions += Number(d);
|
|
169
|
+
const file = basename(path);
|
|
170
|
+
const ext = file.includes(".") ? file.split(".").pop().toLowerCase() : "";
|
|
171
|
+
const lang = LANG[ext];
|
|
172
|
+
if (lang && !NOISE.has(file))
|
|
173
|
+
langLines.set(lang, (langLines.get(lang) ?? 0) + Number(a) + Number(d));
|
|
174
|
+
}
|
|
175
|
+
weeks.set(ws, w);
|
|
176
|
+
days.set(date, day);
|
|
177
|
+
}
|
|
178
|
+
return { weeks, days, langLines };
|
|
179
|
+
}
|
|
180
|
+
function buckets(t) {
|
|
181
|
+
return {
|
|
182
|
+
weeks: [...t.weeks.values()].sort((x, y) => x.weekStart.localeCompare(y.weekStart)),
|
|
183
|
+
days: [...t.days.values()].sort((x, y) => x.date.localeCompare(y.date)),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
const EMPTY_TALLY = () => ({ weeks: new Map(), days: new Map(), langLines: new Map() });
|
|
187
|
+
function countRepo(repo, emails, since, salt, sendNames, fetch) {
|
|
188
|
+
const info = remoteInfo(repo);
|
|
189
|
+
const localEmail = git(repo, "config", "user.email")?.trim();
|
|
190
|
+
const all = [...new Set([...emails, ...(localEmail ? [localEmail] : [])])].filter(Boolean);
|
|
191
|
+
if (all.length === 0)
|
|
192
|
+
return null;
|
|
193
|
+
const ref = defaultRef(repo);
|
|
194
|
+
if (fetch)
|
|
195
|
+
refresh(repo, ref);
|
|
196
|
+
// --fixed-strings: emails like 123+login@users.noreply.github.com would otherwise be read as regex.
|
|
197
|
+
const common = ["--no-merges", "--fixed-strings", `--since=${since}`, "--numstat", "--date=iso-strict", "--format=%x1e%H%x1f%aI%x1f%ae"];
|
|
198
|
+
const authors = all.map((e) => `--author=${e}`);
|
|
199
|
+
const out = git(repo, "log", ref, ...common, ...authors);
|
|
200
|
+
if (out === null)
|
|
201
|
+
return null;
|
|
202
|
+
// Everything on any other local or remote branch that the default branch has not taken in yet.
|
|
203
|
+
const pendingOut = git(repo, "log", "--all", "--not", ref, ...common, ...authors);
|
|
204
|
+
const merged = tally(out, all);
|
|
205
|
+
const pending = pendingOut === null ? EMPTY_TALLY() : tally(pendingOut, all);
|
|
206
|
+
if (merged.weeks.size === 0 && pending.weeks.size === 0)
|
|
207
|
+
return null;
|
|
208
|
+
const langLines = merged.langLines.size > 0 ? merged.langLines : pending.langLines;
|
|
209
|
+
const language = [...langLines.entries()].sort((x, y) => y[1] - x[1])[0]?.[0] ?? null;
|
|
210
|
+
return {
|
|
211
|
+
remoteHash: createHmac("sha256", salt).update(info.key).digest("hex"),
|
|
212
|
+
name: sendNames ? info.label : null,
|
|
213
|
+
language,
|
|
214
|
+
...buckets(merged),
|
|
215
|
+
pending: buckets(pending),
|
|
216
|
+
path: repo,
|
|
217
|
+
label: info.label,
|
|
218
|
+
isWorktree: isWorktree(repo),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
// ---------- server ----------
|
|
222
|
+
async function post(server, path, body, token) {
|
|
223
|
+
const res = await fetch(`${server}${path}`, {
|
|
224
|
+
method: "POST",
|
|
225
|
+
headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
226
|
+
body: JSON.stringify(body),
|
|
227
|
+
});
|
|
228
|
+
const text = await res.text();
|
|
229
|
+
let parsed = null;
|
|
230
|
+
try {
|
|
231
|
+
parsed = JSON.parse(text);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
/* non-JSON error body */
|
|
235
|
+
}
|
|
236
|
+
return { status: res.status, body: parsed };
|
|
237
|
+
}
|
|
238
|
+
function summarize(reports) {
|
|
239
|
+
for (const r of reports) {
|
|
240
|
+
const a = r.weeks.reduce((n, w) => n + w.additions, 0);
|
|
241
|
+
const d = r.weeks.reduce((n, w) => n + w.deletions, 0);
|
|
242
|
+
const cm = r.weeks.reduce((n, w) => n + w.commits, 0);
|
|
243
|
+
const pending = r.pending.weeks.reduce((n, w) => n + w.commits, 0);
|
|
244
|
+
log(` ${r.label.padEnd(40)} ${String(cm).padStart(5)} commits +${a} −${d}${pending > 0 ? ` (${pending} pending)` : ""}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
async function confirm(question) {
|
|
248
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
249
|
+
const answer = (await rl.question(`${question} [Y/n] `)).trim().toLowerCase();
|
|
250
|
+
rl.close();
|
|
251
|
+
return answer === "" || answer === "y" || answer === "yes";
|
|
252
|
+
}
|
|
253
|
+
function count(c, since) {
|
|
254
|
+
const repos = findRepos(c.roots, c.repos);
|
|
255
|
+
const reports = [];
|
|
256
|
+
const seen = new Set();
|
|
257
|
+
const fetch = !args.includes("--no-fetch");
|
|
258
|
+
for (const r of repos) {
|
|
259
|
+
// Worktrees and extra clones share a remote: fetch and count the primary clone only (they read the same origin/HEAD).
|
|
260
|
+
const key = remoteInfo(r).key;
|
|
261
|
+
if (seen.has(key))
|
|
262
|
+
continue;
|
|
263
|
+
seen.add(key);
|
|
264
|
+
const rep = countRepo(r, c.emails, since, c.salt, c.sendNames, fetch);
|
|
265
|
+
if (rep)
|
|
266
|
+
reports.push(rep);
|
|
267
|
+
}
|
|
268
|
+
return { scanned: repos.length, reports };
|
|
269
|
+
}
|
|
270
|
+
async function upload(c, reports) {
|
|
271
|
+
const payload = reports.map(({ remoteHash, name, language, weeks, days, pending }) => ({ remoteHash, name, language, weeks, days, pending }));
|
|
272
|
+
const { status, body } = await post(c.server, "/api/ingest", { repos: payload }, c.token);
|
|
273
|
+
if (status !== 200 || !body) {
|
|
274
|
+
c.lastSync = { at: new Date().toISOString(), repos: 0, weeks: 0, error: `server answered ${status}` };
|
|
275
|
+
saveConfig(c);
|
|
276
|
+
throw new Error(status === 401 ? "this computer is no longer linked; run `gitstats link` again" : `sync failed: HTTP ${status}`);
|
|
277
|
+
}
|
|
278
|
+
c.lastSync = { at: new Date().toISOString(), repos: body.repos, weeks: body.weeks };
|
|
279
|
+
saveConfig(c);
|
|
280
|
+
return body;
|
|
281
|
+
}
|
|
282
|
+
async function sync(c, quiet = false) {
|
|
283
|
+
const since = new Date(Date.now() - DAYS * 86_400_000).toISOString().slice(0, 10);
|
|
284
|
+
const { scanned, reports } = count(c, since);
|
|
285
|
+
const body = await upload(c, reports);
|
|
286
|
+
if (!quiet) {
|
|
287
|
+
log(`scanned ${scanned} repos, ${reports.length} with your commits in the last year, ${body.weeks} weekly rows sent`);
|
|
288
|
+
summarize(reports);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// ---------- scheduler ----------
|
|
292
|
+
const BIN = join(DIR, "bin");
|
|
293
|
+
function installSelf() {
|
|
294
|
+
const here = dirname(fileURLToPath(import.meta.url)); // .../dist
|
|
295
|
+
const pkgRoot = resolve(here, "..");
|
|
296
|
+
rmSync(SELF, { recursive: true, force: true });
|
|
297
|
+
mkdirSync(SELF, { recursive: true });
|
|
298
|
+
cpSync(join(pkgRoot, "dist"), join(SELF, "dist"), { recursive: true });
|
|
299
|
+
cpSync(join(pkgRoot, "package.json"), join(SELF, "package.json"));
|
|
300
|
+
return writeShim();
|
|
301
|
+
}
|
|
302
|
+
/** A `gitstats` command for shells that have ~/.gitstats/bin on PATH; the npx form works regardless. */
|
|
303
|
+
function writeShim() {
|
|
304
|
+
const script = join(SELF, "dist", "cli.js");
|
|
305
|
+
mkdirSync(BIN, { recursive: true });
|
|
306
|
+
if (platform() === "win32") {
|
|
307
|
+
writeFileSync(join(BIN, "gitstats.cmd"), `@echo off\r\n"${process.execPath}" "${script}" %*\r\n`);
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
writeFileSync(join(BIN, "gitstats"), `#!/bin/sh\nexec "${process.execPath}" "${script}" "$@"\n`, { mode: 0o755 });
|
|
311
|
+
}
|
|
312
|
+
return script;
|
|
313
|
+
}
|
|
314
|
+
function installedVersion() {
|
|
315
|
+
try {
|
|
316
|
+
const pkg = JSON.parse(readFileSync(join(SELF, "package.json"), "utf8"));
|
|
317
|
+
const v = typeof pkg === "object" && pkg !== null ? pkg.version : null;
|
|
318
|
+
return typeof v === "string" ? v : "unknown";
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
return "unknown";
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Replace ~/.gitstats/cli with the latest published package. The schedule points at an absolute
|
|
326
|
+
* path inside it and the config lives beside it, so neither is touched.
|
|
327
|
+
*/
|
|
328
|
+
function update() {
|
|
329
|
+
const before = installedVersion();
|
|
330
|
+
const tmp = mkdtempSync(join(tmpdir(), "gitstats-update-"));
|
|
331
|
+
try {
|
|
332
|
+
const pack = spawnSync("npm", ["pack", `${PKG}@latest`, "--pack-destination", tmp], {
|
|
333
|
+
encoding: "utf8",
|
|
334
|
+
timeout: 120_000,
|
|
335
|
+
shell: platform() === "win32",
|
|
336
|
+
});
|
|
337
|
+
if (pack.status !== 0) {
|
|
338
|
+
// npm says why far better than we could (404, offline, proxy); its last line is just a log path.
|
|
339
|
+
const why = (pack.stderr ?? "")
|
|
340
|
+
.split("\n")
|
|
341
|
+
.map((l) => l.replace(/^npm (error|ERR!)\s*/, "").trim())
|
|
342
|
+
.find((l) => l.length > 0 && !l.startsWith("A complete log")) ?? "is npm on PATH?";
|
|
343
|
+
throw new Error(`could not download ${PKG}@latest — ${why}`);
|
|
344
|
+
}
|
|
345
|
+
const tgz = readdirSync(tmp).find((f) => f.endsWith(".tgz"));
|
|
346
|
+
if (!tgz)
|
|
347
|
+
throw new Error("npm pack downloaded nothing");
|
|
348
|
+
// Unpack first: only replace the installed copy once we know the download is good.
|
|
349
|
+
const untar = spawnSync("tar", ["-xzf", join(tmp, tgz), "-C", tmp], { timeout: 60_000 });
|
|
350
|
+
if (untar.status !== 0)
|
|
351
|
+
throw new Error("could not unpack the download (is tar available?)");
|
|
352
|
+
const root = join(tmp, "package");
|
|
353
|
+
if (!existsSync(join(root, "dist", "cli.js")))
|
|
354
|
+
throw new Error("the published package has no dist/cli.js");
|
|
355
|
+
rmSync(join(SELF, "dist"), { recursive: true, force: true });
|
|
356
|
+
mkdirSync(SELF, { recursive: true });
|
|
357
|
+
cpSync(join(root, "dist"), join(SELF, "dist"), { recursive: true });
|
|
358
|
+
cpSync(join(root, "package.json"), join(SELF, "package.json"));
|
|
359
|
+
writeShim();
|
|
360
|
+
log(`updated ${before} → ${installedVersion()} · config and schedule untouched`);
|
|
361
|
+
}
|
|
362
|
+
finally {
|
|
363
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
function installSchedule() {
|
|
367
|
+
const script = installSelf();
|
|
368
|
+
const node = process.execPath;
|
|
369
|
+
const os = platform();
|
|
370
|
+
if (os === "darwin") {
|
|
371
|
+
const plist = join(HOME, "Library", "LaunchAgents", "com.gitstats.sync.plist");
|
|
372
|
+
mkdirSync(dirname(plist), { recursive: true });
|
|
373
|
+
writeFileSync(plist, `<?xml version="1.0" encoding="UTF-8"?>
|
|
374
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
375
|
+
<plist version="1.0"><dict>
|
|
376
|
+
<key>Label</key><string>com.gitstats.sync</string>
|
|
377
|
+
<key>ProgramArguments</key><array><string>${node}</string><string>${script}</string><string>sync</string><string>--quiet</string></array>
|
|
378
|
+
<key>StartInterval</key><integer>21600</integer>
|
|
379
|
+
<key>RunAtLoad</key><true/>
|
|
380
|
+
<key>StandardOutPath</key><string>${join(DIR, "sync.log")}</string>
|
|
381
|
+
<key>StandardErrorPath</key><string>${join(DIR, "sync.log")}</string>
|
|
382
|
+
</dict></plist>
|
|
383
|
+
`);
|
|
384
|
+
spawnSync("launchctl", ["unload", plist], { stdio: "ignore" });
|
|
385
|
+
spawnSync("launchctl", ["load", plist], { stdio: "ignore" });
|
|
386
|
+
return "launchd agent com.gitstats.sync (every 6h, and at login)";
|
|
387
|
+
}
|
|
388
|
+
if (os === "win32") {
|
|
389
|
+
const ps = `$a = New-ScheduledTaskAction -Execute '${node}' -Argument '"${script}" sync --quiet'; ` +
|
|
390
|
+
`$t = New-ScheduledTaskTrigger -Daily -At 12:00; ` +
|
|
391
|
+
`$s = New-ScheduledTaskSettingsSet -StartWhenAvailable -RunOnlyIfNetworkAvailable; ` +
|
|
392
|
+
`Register-ScheduledTask -TaskName 'gitstats-sync' -Action $a -Trigger $t -Settings $s -Force | Out-Null`;
|
|
393
|
+
spawnSync("powershell", ["-NoProfile", "-Command", ps], { stdio: "ignore" });
|
|
394
|
+
return "Task Scheduler task gitstats-sync (daily 12:00, runs late if missed)";
|
|
395
|
+
}
|
|
396
|
+
const unitDir = join(HOME, ".config", "systemd", "user");
|
|
397
|
+
mkdirSync(unitDir, { recursive: true });
|
|
398
|
+
writeFileSync(join(unitDir, "gitstats-sync.service"), `[Unit]\nDescription=gitstats sync\n\n[Service]\nType=oneshot\nExecStart=${node} ${script} sync --quiet\n`);
|
|
399
|
+
writeFileSync(join(unitDir, "gitstats-sync.timer"), `[Unit]\nDescription=gitstats daily sync\n\n[Timer]\nOnCalendar=daily\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n`);
|
|
400
|
+
spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
|
|
401
|
+
spawnSync("systemctl", ["--user", "enable", "--now", "gitstats-sync.timer"], { stdio: "ignore" });
|
|
402
|
+
return "systemd user timer gitstats-sync (daily, catches up if missed)";
|
|
403
|
+
}
|
|
404
|
+
function removeSchedule() {
|
|
405
|
+
const os = platform();
|
|
406
|
+
if (os === "darwin") {
|
|
407
|
+
const plist = join(HOME, "Library", "LaunchAgents", "com.gitstats.sync.plist");
|
|
408
|
+
spawnSync("launchctl", ["unload", plist], { stdio: "ignore" });
|
|
409
|
+
rmSync(plist, { force: true });
|
|
410
|
+
}
|
|
411
|
+
else if (os === "win32") {
|
|
412
|
+
spawnSync("schtasks", ["/Delete", "/TN", "gitstats-sync", "/F"], { stdio: "ignore" });
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
spawnSync("systemctl", ["--user", "disable", "--now", "gitstats-sync.timer"], { stdio: "ignore" });
|
|
416
|
+
rmSync(join(HOME, ".config", "systemd", "user", "gitstats-sync.timer"), { force: true });
|
|
417
|
+
rmSync(join(HOME, ".config", "systemd", "user", "gitstats-sync.service"), { force: true });
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
function openBrowser(url) {
|
|
421
|
+
const os = platform();
|
|
422
|
+
const [bin, a] = os === "darwin" ? ["open", [url]] : os === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
423
|
+
spawnSync(bin, a, { stdio: "ignore" });
|
|
424
|
+
}
|
|
425
|
+
// ---------- commands ----------
|
|
426
|
+
async function revokeOnServer(c) {
|
|
427
|
+
const res = await fetch(`${c.server}/api/cli/unlink`, { method: "DELETE", headers: { Authorization: `Bearer ${c.token}` } }).catch(() => null);
|
|
428
|
+
return res?.ok ?? false;
|
|
429
|
+
}
|
|
430
|
+
async function link() {
|
|
431
|
+
const server = (args.includes("--server") ? args[args.indexOf("--server") + 1] : undefined) ?? DEFAULT_SERVER;
|
|
432
|
+
const previous = loadConfig();
|
|
433
|
+
if (previous) {
|
|
434
|
+
log(` this computer is already linked as ${previous.login}; replacing the link${(await revokeOnServer(previous)) ? " (old one revoked)" : ""}`);
|
|
435
|
+
}
|
|
436
|
+
const machine = hostname();
|
|
437
|
+
const start = await post(server, "/api/cli/device", { machine });
|
|
438
|
+
if (start.status !== 200 || !start.body)
|
|
439
|
+
throw new Error(`could not reach ${server} (HTTP ${start.status})`);
|
|
440
|
+
log(`\n Open this page and confirm: ${start.body.verifyUrl}`);
|
|
441
|
+
log(` Code: ${start.body.code}\n`);
|
|
442
|
+
openBrowser(start.body.verifyUrl);
|
|
443
|
+
const deadline = Date.now() + start.body.expiresIn * 1000;
|
|
444
|
+
let done = null;
|
|
445
|
+
while (Date.now() < deadline) {
|
|
446
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
447
|
+
const p = await post(server, "/api/cli/device/poll", { pollSecret: start.body.pollSecret });
|
|
448
|
+
if (p.status === 410)
|
|
449
|
+
throw new Error("the code expired; run link again");
|
|
450
|
+
if (p.body?.status === "ok" && p.body.token && p.body.login && p.body.salt) {
|
|
451
|
+
done = { token: p.body.token, login: p.body.login, githubId: p.body.githubId ?? null, salt: p.body.salt };
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (!done)
|
|
456
|
+
throw new Error("timed out waiting for confirmation");
|
|
457
|
+
const emails = new Set();
|
|
458
|
+
const ge = globalEmail();
|
|
459
|
+
if (ge)
|
|
460
|
+
emails.add(ge);
|
|
461
|
+
if (done.githubId !== null)
|
|
462
|
+
emails.add(`${done.githubId}+${done.login}@users.noreply.github.com`);
|
|
463
|
+
const roots = args.flatMap((a, i) => (a === "--root" && args[i + 1] ? [resolve(args[i + 1])] : []));
|
|
464
|
+
const c = {
|
|
465
|
+
server,
|
|
466
|
+
token: done.token,
|
|
467
|
+
salt: done.salt,
|
|
468
|
+
sendNames: false,
|
|
469
|
+
login: done.login,
|
|
470
|
+
githubId: done.githubId,
|
|
471
|
+
machine,
|
|
472
|
+
roots: roots.length > 0 ? roots : [HOME],
|
|
473
|
+
repos: [],
|
|
474
|
+
emails: [...emails],
|
|
475
|
+
};
|
|
476
|
+
saveConfig(c);
|
|
477
|
+
log(` linked as ${done.login} · counting commits by: ${[...emails].join(", ") || "(no email found; run: gitstats emails add you@example.com)"}`);
|
|
478
|
+
log(` scanning ${c.roots.join(", ")} for git repos… (this first run can take a minute)\n`);
|
|
479
|
+
const since = new Date(Date.now() - DAYS * 86_400_000).toISOString().slice(0, 10);
|
|
480
|
+
const { scanned, reports } = count(c, since);
|
|
481
|
+
log(` found ${scanned} repos, ${reports.length} with your commits in the last year:`);
|
|
482
|
+
summarize(reports);
|
|
483
|
+
log(`\n What gets sent per repo: a keyed hash of its remote URL, the language guess, the weekly numbers above, and commits-per-day counts.`);
|
|
484
|
+
log(` Repo names are NOT sent (turn on later with: gitstats names on).`);
|
|
485
|
+
if (!args.includes("--yes") && !(await confirm(" Upload these numbers to your gitstats profile?"))) {
|
|
486
|
+
rmSync(DIR, { recursive: true, force: true });
|
|
487
|
+
log(" cancelled; nothing was uploaded and the link was removed locally (revoke it on the settings page).");
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const body = await upload(c, reports);
|
|
491
|
+
log(` uploaded ${body.weeks} weekly rows for ${body.repos} repos`);
|
|
492
|
+
const how = installSchedule();
|
|
493
|
+
log(`\n scheduled: ${how}`);
|
|
494
|
+
log(` config: ${CONFIG}`);
|
|
495
|
+
log(`\n done. It re-syncs on its own. To run commands by hand, either use`);
|
|
496
|
+
log(` npx --yes github:yaroslavhaidash/gitstats-cli <command>`);
|
|
497
|
+
log(` or add ${BIN} to your PATH and use \`gitstats <command>\`.`);
|
|
498
|
+
log(` Commands and how to stop: ${server}/docs\n`);
|
|
499
|
+
}
|
|
500
|
+
function requireConfig() {
|
|
501
|
+
const c = loadConfig();
|
|
502
|
+
if (!c)
|
|
503
|
+
throw new Error("not linked yet; run: npx --yes github:yaroslavhaidash/gitstats-cli link");
|
|
504
|
+
return c;
|
|
505
|
+
}
|
|
506
|
+
async function main() {
|
|
507
|
+
switch (cmd) {
|
|
508
|
+
case "link":
|
|
509
|
+
return link();
|
|
510
|
+
case "sync":
|
|
511
|
+
return sync(requireConfig(), args.includes("--quiet"));
|
|
512
|
+
case "status": {
|
|
513
|
+
const c = requireConfig();
|
|
514
|
+
log(`server ${c.server}\nuser ${c.login}\nmachine ${c.machine}\nroots ${c.roots.join(", ")}\nextra ${c.repos.join(", ") || "-"}\nemails ${c.emails.join(", ")}`);
|
|
515
|
+
log(c.lastSync ? `last sync ${c.lastSync.at} · ${c.lastSync.repos} repos · ${c.lastSync.weeks} weeks${c.lastSync.error ? ` · ERROR ${c.lastSync.error}` : ""}` : "last sync never");
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
case "add": {
|
|
519
|
+
const c = requireConfig();
|
|
520
|
+
const p = resolve(args[1] ?? ".");
|
|
521
|
+
if (!existsSync(join(p, ".git")))
|
|
522
|
+
throw new Error(`${p} is not a git repo`);
|
|
523
|
+
if (!c.repos.includes(p))
|
|
524
|
+
c.repos.push(p);
|
|
525
|
+
saveConfig(c);
|
|
526
|
+
log(`tracking ${p}`);
|
|
527
|
+
return sync(c);
|
|
528
|
+
}
|
|
529
|
+
case "roots": {
|
|
530
|
+
const c = requireConfig();
|
|
531
|
+
const d = args[2];
|
|
532
|
+
if (args[1] === "add" && d) {
|
|
533
|
+
const p = resolve(d);
|
|
534
|
+
if (!c.roots.includes(p))
|
|
535
|
+
c.roots.push(p);
|
|
536
|
+
saveConfig(c);
|
|
537
|
+
log(`roots: ${c.roots.join(", ")}`);
|
|
538
|
+
return sync(c);
|
|
539
|
+
}
|
|
540
|
+
log(`roots: ${c.roots.join(", ")}`);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
case "emails": {
|
|
544
|
+
const c = requireConfig();
|
|
545
|
+
const e = args[2];
|
|
546
|
+
if (args[1] === "add" && e) {
|
|
547
|
+
if (!c.emails.includes(e))
|
|
548
|
+
c.emails.push(e);
|
|
549
|
+
saveConfig(c);
|
|
550
|
+
log(`emails: ${c.emails.join(", ")}`);
|
|
551
|
+
return sync(c);
|
|
552
|
+
}
|
|
553
|
+
log(`emails: ${c.emails.join(", ")}`);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
case "names": {
|
|
557
|
+
const c = requireConfig();
|
|
558
|
+
if (args[1] === "on" || args[1] === "off") {
|
|
559
|
+
c.sendNames = args[1] === "on";
|
|
560
|
+
saveConfig(c);
|
|
561
|
+
log(`repo names: ${c.sendNames ? "sent (others see them only if your settings allow)" : "not sent"}`);
|
|
562
|
+
return sync(c);
|
|
563
|
+
}
|
|
564
|
+
log(`repo names: ${c.sendNames ? "sent" : "not sent"}`);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
case "pause":
|
|
568
|
+
requireConfig();
|
|
569
|
+
removeSchedule();
|
|
570
|
+
log("background sync stopped; `gitstats resume` starts it again, `gitstats sync` still works by hand");
|
|
571
|
+
return;
|
|
572
|
+
case "resume":
|
|
573
|
+
requireConfig();
|
|
574
|
+
log(`background sync: ${installSchedule()}`);
|
|
575
|
+
return;
|
|
576
|
+
case "update":
|
|
577
|
+
requireConfig();
|
|
578
|
+
return update();
|
|
579
|
+
case "unlink": {
|
|
580
|
+
const c = loadConfig();
|
|
581
|
+
removeSchedule();
|
|
582
|
+
if (c)
|
|
583
|
+
log((await revokeOnServer(c)) ? "revoked on the server" : "could not reach the server; revoke this computer on the settings page");
|
|
584
|
+
rmSync(DIR, { recursive: true, force: true });
|
|
585
|
+
log("unlinked");
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
default:
|
|
589
|
+
log("usage: gitstats <link [--root <dir>]... [--yes] | sync | status | add <path> | roots add <dir> | emails add <email> | names on|off | pause | resume | update | unlink>");
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
main().catch((e) => {
|
|
593
|
+
console.error(`gitstats: ${e instanceof Error ? e.message : String(e)}`);
|
|
594
|
+
process.exit(1);
|
|
595
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yaroslavhaidash/gitstats-cli",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Counts your commits and lines locally and sends only the numbers to your gitstats board.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": { "gitstats": "dist/cli.js" },
|
|
8
|
+
"files": ["dist"],
|
|
9
|
+
"engines": { "node": ">=18" },
|
|
10
|
+
"scripts": { "build": "tsc", "typecheck": "tsc --noEmit" },
|
|
11
|
+
"devDependencies": { "@types/node": "^20", "typescript": "^5" }
|
|
12
|
+
}
|