githate 1.0.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 ADDED
@@ -0,0 +1,105 @@
1
+ # GitHate CLI 🕵️‍♂️
2
+
3
+ **Track who unfollowed you on GitHub directly from your terminal.**
4
+
5
+ `githate` is a modern, fast, and beautiful CLI tool that helps you keep track of your GitHub followers. It detects who unfollowed you since the last check and lets you manage your following list with ease.
6
+
7
+ ![GitHate CLI Demo](https://placehold.co/600x400?text=GitHate+CLI+Demo)
8
+
9
+ ## Features
10
+
11
+ - 🕵️ **Track Unfollowers**: Instantly see who stopped following you.
12
+ - 📈 **Track New Followers**: See who started following you.
13
+ - 👥 **Manage Relationships**: List followers, following, and follow/unfollow users.
14
+ - 🔐 **Secure**: Your Personal Access Token is stored locally on your machine.
15
+ - 💅 **Beautiful UI**: Built with `@clack/prompts` and `picocolors` for a great experience.
16
+
17
+ ## Installation
18
+
19
+ You can install `githate` globally using npm:
20
+
21
+ ```bash
22
+ npm install -g githate
23
+ ```
24
+
25
+ _Note: You may need to use `sudo` on macOS/Linux if you have permission issues._
26
+
27
+ ## Setup
28
+
29
+ 1. **Generate a GitHub Personal Access Token (PAT)**:
30
+ - Go to [GitHub Settings > Developer settings > Personal access tokens > Tokens (classic)](https://github.com/settings/tokens).
31
+ - Click **Generate new token (classic)**.
32
+ - Give it a note (e.g., "GitHate CLI").
33
+ - Select the **`read:user`** and **`user:follow`** scopes.
34
+ - Click **Generate token** and copy it.
35
+
36
+ 2. **Login**:
37
+ Run the login command and paste your token when prompted:
38
+ ```bash
39
+ githate login
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ ### Check for Haters (Unfollowers)
45
+
46
+ This is the main feature. Run this command to compare your current followers with the last saved state.
47
+
48
+ ```bash
49
+ githate check
50
+ ```
51
+
52
+ _On the first run, it will just save your current followers._
53
+
54
+ ### List Followers
55
+
56
+ ```bash
57
+ githate followers
58
+ ```
59
+
60
+ ### List Following
61
+
62
+ ```bash
63
+ githate following
64
+ ```
65
+
66
+ ### Follow a User
67
+
68
+ ```bash
69
+ githate follow <username>
70
+ ```
71
+
72
+ ### Unfollow a User
73
+
74
+ ```bash
75
+ githate unfollow <username>
76
+ ```
77
+
78
+ ## Automation (Daily Check)
79
+
80
+ You can set up a cron job or task scheduler to run `githate check` daily.
81
+
82
+ ### macOS / Linux (Cron)
83
+
84
+ 1. Open your crontab:
85
+ ```bash
86
+ crontab -e
87
+ ```
88
+ 2. Add the following line to run everyday at 9 AM:
89
+ ```bash
90
+ 0 9 * * * /usr/local/bin/githate check >> /tmp/githate.log 2>&1
91
+ ```
92
+ _(Make sure to use the correct path to `githate`. You can find it with `which githate`)_
93
+
94
+ ### Windows (Task Scheduler)
95
+
96
+ 1. Open **Task Scheduler**.
97
+ 2. Create a Basic Task.
98
+ 3. Set the trigger to **Daily**.
99
+ 4. Set the action to **Start a program**.
100
+ 5. Program/script: `githate` (or full path to `githate.cmd`).
101
+ 6. Add arguments: `check`.
102
+
103
+ ## License
104
+
105
+ ISC
package/bin/hater.js ADDED
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { readFileSync } from "node:fs";
4
+ import { join, dirname } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const pkg = JSON.parse(
9
+ readFileSync(join(__dirname, "../package.json"), "utf-8"),
10
+ );
11
+
12
+ const program = new Command();
13
+
14
+ program
15
+ .name("githate")
16
+ .description("Track who unfollowed you on GitHub")
17
+ .version(pkg.version);
18
+
19
+ program
20
+ .command("login")
21
+ .description("Login to GitHub with a Personal Access Token")
22
+ .action(async () => {
23
+ const { login } = await import("../lib/commands/login.js");
24
+ await login();
25
+ });
26
+
27
+ program
28
+ .command("check")
29
+ .description("Check for new unfollowers")
30
+ .action(async () => {
31
+ const { check } = await import("../lib/commands/check.js");
32
+ await check();
33
+ });
34
+
35
+ program
36
+ .command("followers")
37
+ .description("List your followers")
38
+ .action(async () => {
39
+ const { followers } = await import("../lib/commands/followers.js");
40
+ await followers();
41
+ });
42
+
43
+ program
44
+ .command("following")
45
+ .description("List who you are following")
46
+ .action(async () => {
47
+ const { following } = await import("../lib/commands/following.js");
48
+ await following();
49
+ });
50
+
51
+ program
52
+ .command("follow <username>")
53
+ .description("Follow a user")
54
+ .action(async (username) => {
55
+ const { follow } = await import("../lib/commands/follow.js");
56
+ await follow(username);
57
+ });
58
+
59
+ program
60
+ .command("unfollow <username>")
61
+ .description("Unfollow a user")
62
+ .action(async (username) => {
63
+ const { unfollow } = await import("../lib/commands/unfollow.js");
64
+ await unfollow(username);
65
+ });
66
+
67
+ program.parse(process.argv);
@@ -0,0 +1,98 @@
1
+ import { getOctokit } from "../utils/auth.js";
2
+ import {
3
+ getStoredFollowers,
4
+ setStoredFollowers,
5
+ setLastCheck,
6
+ getLastCheck,
7
+ } from "../utils/store.js";
8
+ import {
9
+ displayIntro,
10
+ displaySuccess,
11
+ displayError,
12
+ createSpinner,
13
+ displayInfo,
14
+ displayWarning,
15
+ displayOutro,
16
+ } from "../ui/display.js";
17
+ import color from "picocolors";
18
+
19
+ export const check = async () => {
20
+ displayIntro();
21
+ const s = createSpinner();
22
+
23
+ try {
24
+ const octokit = getOctokit();
25
+ s.start("Fetching current followers...");
26
+
27
+ const currentFollowers = await octokit.paginate(
28
+ octokit.rest.users.listFollowersForAuthenticatedUser,
29
+ {
30
+ per_page: 100,
31
+ },
32
+ );
33
+ const currentFollowerLogins = currentFollowers.map((f) => f.login);
34
+
35
+ s.stop("Followers fetched");
36
+
37
+ const storedFollowers = getStoredFollowers();
38
+ const lastCheck = getLastCheck();
39
+
40
+ if (storedFollowers.length === 0) {
41
+ displayInfo(
42
+ "First run detected! Storing current followers for future checks.",
43
+ );
44
+ setStoredFollowers(currentFollowerLogins);
45
+ setLastCheck(new Date().toISOString());
46
+ displayOutro(`Tracking ${currentFollowerLogins.length} followers.`);
47
+ return;
48
+ }
49
+
50
+ // Find new followers
51
+ const newFollowers = currentFollowerLogins.filter(
52
+ (login) => !storedFollowers.includes(login),
53
+ );
54
+
55
+ // Find unfollowers (The Haters)
56
+ const unfollowers = storedFollowers.filter(
57
+ (login) => !currentFollowerLogins.includes(login),
58
+ );
59
+
60
+ console.log("");
61
+ if (lastCheck) {
62
+ displayInfo(`Last check: ${new Date(lastCheck).toLocaleString()}`);
63
+ }
64
+ console.log("");
65
+
66
+ if (newFollowers.length > 0) {
67
+ displaySuccess(`New Followers (+${newFollowers.length}):`);
68
+ newFollowers.forEach((login) =>
69
+ console.log(` ${color.green("+")} ${login}`),
70
+ );
71
+ console.log("");
72
+ } else {
73
+ console.log(color.dim("No new followers."));
74
+ }
75
+
76
+ if (unfollowers.length > 0) {
77
+ displayWarning(`Unfollowers (-${unfollowers.length}):`); // Using warning for "haters"
78
+ unfollowers.forEach((login) =>
79
+ console.log(` ${color.red("-")} ${login}`),
80
+ );
81
+ console.log("");
82
+ displayInfo(
83
+ 'Consider using "hater unfollow <username>" if you want to respond.',
84
+ );
85
+ } else {
86
+ console.log(color.dim("No new unfollowers. Everyone still loves you!"));
87
+ }
88
+
89
+ // Update store
90
+ setStoredFollowers(currentFollowerLogins);
91
+ setLastCheck(new Date().toISOString());
92
+
93
+ displayOutro("Check complete & database updated.");
94
+ } catch (error) {
95
+ s.stop("Check failed");
96
+ displayError(error.message);
97
+ }
98
+ };
@@ -0,0 +1,27 @@
1
+ import { getOctokit } from "../utils/auth.js";
2
+ import {
3
+ displayIntro,
4
+ displaySuccess,
5
+ displayError,
6
+ createSpinner,
7
+ } from "../ui/display.js";
8
+
9
+ export const follow = async (username) => {
10
+ displayIntro();
11
+ const s = createSpinner();
12
+
13
+ try {
14
+ const octokit = getOctokit();
15
+ s.start(`Following ${username}...`);
16
+
17
+ await octokit.rest.users.follow({
18
+ username,
19
+ });
20
+
21
+ s.stop(`Followed ${username}`);
22
+ displaySuccess(`Successfully followed ${username}`);
23
+ } catch (error) {
24
+ s.stop(`Failed to follow ${username}`);
25
+ displayError(error.message);
26
+ }
27
+ };
@@ -0,0 +1,43 @@
1
+ import { getOctokit } from "../utils/auth.js";
2
+ import {
3
+ displayIntro,
4
+ displayOutro,
5
+ createSpinner,
6
+ displayError,
7
+ } from "../ui/display.js";
8
+ import color from "picocolors";
9
+
10
+ export const followers = async () => {
11
+ displayIntro();
12
+ const s = createSpinner();
13
+
14
+ try {
15
+ const octokit = getOctokit();
16
+ s.start("Fetching followers...");
17
+
18
+ const followersList = await octokit.paginate(
19
+ octokit.rest.users.listFollowersForAuthenticatedUser,
20
+ {
21
+ per_page: 100,
22
+ },
23
+ );
24
+
25
+ s.stop(`Found ${followersList.length} followers`);
26
+
27
+ if (followersList.length === 0) {
28
+ displayOutro("You have no followers yet.");
29
+ return;
30
+ }
31
+
32
+ console.log(""); // New line
33
+ followersList.forEach((follower) => {
34
+ console.log(`${color.green("•")} ${follower.login}`);
35
+ });
36
+ console.log("");
37
+
38
+ displayOutro("End of followers list");
39
+ } catch (error) {
40
+ s.stop("Failed to fetch followers");
41
+ displayError(error.message);
42
+ }
43
+ };
@@ -0,0 +1,43 @@
1
+ import { getOctokit } from "../utils/auth.js";
2
+ import {
3
+ displayIntro,
4
+ displayOutro,
5
+ createSpinner,
6
+ displayError,
7
+ } from "../ui/display.js";
8
+ import color from "picocolors";
9
+
10
+ export const following = async () => {
11
+ displayIntro();
12
+ const s = createSpinner();
13
+
14
+ try {
15
+ const octokit = getOctokit();
16
+ s.start("Fetching following list...");
17
+
18
+ const followingList = await octokit.paginate(
19
+ octokit.rest.users.listFollowedByAuthenticated,
20
+ {
21
+ per_page: 100,
22
+ },
23
+ );
24
+
25
+ s.stop(`You are following ${followingList.length} users`);
26
+
27
+ if (followingList.length === 0) {
28
+ displayOutro("You are not following anyone.");
29
+ return;
30
+ }
31
+
32
+ console.log(""); // New line
33
+ followingList.forEach((user) => {
34
+ console.log(`${color.blue("•")} ${user.login}`);
35
+ });
36
+ console.log("");
37
+
38
+ displayOutro("End of following list");
39
+ } catch (error) {
40
+ s.stop("Failed to fetch following list");
41
+ displayError(error.message);
42
+ }
43
+ };
@@ -0,0 +1,39 @@
1
+ import { password, isCancel, cancel } from "@clack/prompts";
2
+ import { setStoredToken, verifyToken } from "../utils/auth.js";
3
+ import {
4
+ displayIntro,
5
+ displaySuccess,
6
+ displayError,
7
+ displayOutro,
8
+ createSpinner,
9
+ } from "../ui/display.js";
10
+
11
+ export const login = async () => {
12
+ displayIntro();
13
+
14
+ const token = await password({
15
+ message: "Enter your GitHub Personal Access Token",
16
+ mask: "*",
17
+ });
18
+
19
+ if (isCancel(token)) {
20
+ cancel("Login cancelled");
21
+ process.exit(0);
22
+ }
23
+
24
+ const s = createSpinner();
25
+ s.start("Verifying token...");
26
+
27
+ try {
28
+ const user = await verifyToken(token);
29
+ setStoredToken(token);
30
+ s.stop("Token verified!");
31
+ displaySuccess(`Logged in as ${user.login}`);
32
+ } catch (error) {
33
+ s.stop("Verification failed");
34
+ displayError(error.message);
35
+ process.exit(1);
36
+ }
37
+
38
+ displayOutro("You are ready to track the haters!");
39
+ };
@@ -0,0 +1,27 @@
1
+ import { getOctokit } from "../utils/auth.js";
2
+ import {
3
+ displayIntro,
4
+ displaySuccess,
5
+ displayError,
6
+ createSpinner,
7
+ } from "../ui/display.js";
8
+
9
+ export const unfollow = async (username) => {
10
+ displayIntro();
11
+ const s = createSpinner();
12
+
13
+ try {
14
+ const octokit = getOctokit();
15
+ s.start(`Unfollowing ${username}...`);
16
+
17
+ await octokit.rest.users.unfollow({
18
+ username,
19
+ });
20
+
21
+ s.stop(`Unfollowed ${username}`);
22
+ displaySuccess(`Successfully unfollowed ${username}`);
23
+ } catch (error) {
24
+ s.stop(`Failed to unfollow ${username}`);
25
+ displayError(error.message);
26
+ }
27
+ };
@@ -0,0 +1,30 @@
1
+ import { intro, outro, spinner, note, log } from "@clack/prompts";
2
+ import color from "picocolors";
3
+
4
+ export const displayIntro = () => {
5
+ intro(color.bgRed(color.white(" GITHATE CLI ")));
6
+ };
7
+
8
+ export const displayOutro = (message) => {
9
+ outro(message);
10
+ };
11
+
12
+ export const displayError = (message) => {
13
+ log.error(color.red(message));
14
+ };
15
+
16
+ export const displaySuccess = (message) => {
17
+ log.success(color.green(message));
18
+ };
19
+
20
+ export const displayInfo = (message) => {
21
+ log.info(color.cyan(message));
22
+ };
23
+
24
+ export const displayWarning = (message) => {
25
+ log.warn(color.yellow(message));
26
+ };
27
+
28
+ export const createSpinner = () => {
29
+ return spinner();
30
+ };
@@ -0,0 +1,27 @@
1
+ import { Octokit } from "@octokit/rest";
2
+ import { getStoredToken } from "./store.js";
3
+ import { displayError } from "../ui/display.js";
4
+
5
+ let octokitInstance = null;
6
+
7
+ export const getOctokit = () => {
8
+ if (octokitInstance) return octokitInstance;
9
+
10
+ const token = getStoredToken();
11
+ if (!token) {
12
+ throw new Error('Not logged in. Run "hater login" first.');
13
+ }
14
+
15
+ octokitInstance = new Octokit({ auth: token });
16
+ return octokitInstance;
17
+ };
18
+
19
+ export const verifyToken = async (token) => {
20
+ try {
21
+ const octokit = new Octokit({ auth: token });
22
+ const { data } = await octokit.rest.users.getAuthenticated();
23
+ return data;
24
+ } catch (error) {
25
+ throw new Error("Invalid token or network error.");
26
+ }
27
+ };
@@ -0,0 +1,32 @@
1
+ import Conf from "conf";
2
+
3
+ const schema = {
4
+ token: {
5
+ type: "string",
6
+ },
7
+ followers: {
8
+ type: "array",
9
+ default: [],
10
+ },
11
+ lastCheck: {
12
+ type: "string",
13
+ },
14
+ };
15
+
16
+ const config = new Conf({
17
+ projectName: "githate-cli",
18
+ schema,
19
+ });
20
+
21
+ export const getStoredToken = () => config.get("token");
22
+ export const setStoredToken = (token) => config.set("token", token);
23
+ export const deleteStoredToken = () => config.delete("token");
24
+
25
+ export const getStoredFollowers = () => config.get("followers");
26
+ export const setStoredFollowers = (followers) =>
27
+ config.set("followers", followers);
28
+
29
+ export const setLastCheck = (date) => config.set("lastCheck", date);
30
+ export const getLastCheck = () => config.get("lastCheck");
31
+
32
+ export const clearStore = () => config.clear();
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "githate",
3
+ "version": "1.0.0",
4
+ "description": "A CLI tool to track who unfollowed you on GitHub.",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "githate": "bin/hater.js"
9
+ },
10
+ "scripts": {
11
+ "test": "echo \"Error: no test specified\" && exit 1"
12
+ },
13
+ "keywords": [
14
+ "github",
15
+ "cli",
16
+ "unfollowers",
17
+ "tracker"
18
+ ],
19
+ "author": "",
20
+ "license": "ISC",
21
+ "dependencies": {
22
+ "@clack/prompts": "^1.0.0",
23
+ "@octokit/rest": "^20.0.0",
24
+ "commander": "^14.0.3",
25
+ "conf": "^15.1.0",
26
+ "picocolors": "^1.1.1"
27
+ }
28
+ }