gh-unstar 2.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/.env.example ADDED
@@ -0,0 +1,3 @@
1
+ # Example .env file
2
+ GITHUB_TOKEN=
3
+ GITHUB_USERNAME=
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chris Thomas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # gh-unstar
2
+
3
+ # gh-unstar
4
+
5
+ [![npm version](https://img.shields.io/npm/v/gh-unstar?style=flat-square&color=blue)](https://www.npmjs.com/package/gh-unstar)
6
+ [![license](https://img.shields.io/npm/l/gh-unstar?style=flat-square&color=green)](https://www.npmjs.com/package/gh-unstar)
7
+
8
+ CLI to remove all starred repositories from a GitHub account
9
+
10
+ ## Features
11
+
12
+ - Unstars all repositories for the authenticated user
13
+ - Confirmation safety check
14
+ - Dry-run support
15
+ - Test mode
16
+ - Additional configurable options (see below)
17
+
18
+ ## Requirements
19
+
20
+ - Node.js 18+
21
+ - A GitHub Personal Access Token (classic)
22
+
23
+ ## Usage
24
+
25
+ npx:
26
+
27
+ ```bash
28
+ npx gh-unstar
29
+ ```
30
+
31
+ or install globally:
32
+
33
+ ```bash
34
+ npm install -g gh-unstar
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ```bash
40
+ gh-unstar
41
+ ```
42
+
43
+ ## Options
44
+
45
+ - `-u, --username <username>`: GitHub username to target.
46
+ - `-t, --token <token>`: GitHub personal access token to authenticate API calls.
47
+ - `-y, --yes`: Skip the confirmation prompt.
48
+ - `--dry-run`: Fetch starred repositories and report the count without changing anything.
49
+ - `--delay <ms>`: Delay in milliseconds between unstar requests (default 100).
50
+ - `--max-retries <n>`: Maximum retries for failed API requests with exponential backoff (default 5).
51
+ - `--per-page <n>`: Results per page when fetching starred repos (max 100, default 100).
52
+ - `--test`: Run without API calls or credentials.
53
+ - `--test-count <n>`: Number of fake starred repositories in test mode (default 25).
54
+ - `--test-fail-every <n>`: Every Nth unstar fails in test mode (0 disables).
55
+
56
+ ## Auth
57
+
58
+ Create a Personal Access Token (classic) with at least `public_repo` scope
59
+ (or `repo` to include private stars).
60
+
61
+ Environment variables:
62
+
63
+ - `GITHUB_TOKEN`
64
+ - `GITHUB_USERNAME` (optional, auto-detected if missing)
65
+
66
+ Example `.env`:
67
+
68
+ ```
69
+ GITHUB_TOKEN=your_token_here
70
+ GITHUB_USERNAME=your_username
71
+ ```
72
+
73
+ ## Safety
74
+
75
+ This tool will unstar all repositories for the account. It **requires** typing
76
+ `YES` unless `--yes` is provided.
77
+
78
+ ## Dev
79
+
80
+ Install dependencies:
81
+
82
+ ```bash
83
+ npm install
84
+ ```
85
+
86
+ Available scripts:
87
+
88
+ - `npm run start`: run the CLI entrypoint locally
89
+ - `npm run lint`: run Biome lint checks
90
+ - `npm run format`: format files in-place using Biome
91
+ - `npm run check`: run Biome formatting and safe lint fixes in one pass
92
+
93
+ ## License
94
+
95
+ MIT
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ const run = require("../src/index");
4
+
5
+ run();
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "gh-unstar",
3
+ "version": "2.0.0",
4
+ "description": "Interactive CLI to remove all GitHub stars from your account",
5
+ "bin": {
6
+ "gh-unstar": "bin/gh-unstar.js"
7
+ },
8
+ "type": "commonjs",
9
+ "files": [
10
+ "bin/",
11
+ "src/",
12
+ "README.md",
13
+ "LICENSE",
14
+ ".env.example"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "scripts": {
20
+ "start": "node bin/gh-unstar.js",
21
+ "lint": "biome lint .",
22
+ "format": "biome format --write .",
23
+ "check": "biome check --write ."
24
+ },
25
+ "keywords": [
26
+ "github",
27
+ "stars",
28
+ "repositories",
29
+ "api",
30
+ "cli",
31
+ "unstar",
32
+ "automation"
33
+ ],
34
+ "license": "MIT",
35
+ "author": {
36
+ "name": "Chris Thomas",
37
+ "url": "https://github.com/chris-c-thomas"
38
+ },
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "https://github.com/chris-c-thomas/gh-unstar.git"
42
+ },
43
+ "bugs": {
44
+ "url": "https://github.com/chris-c-thomas/gh-unstar/issues"
45
+ },
46
+ "homepage": "https://github.com/chris-c-thomas/gh-unstar",
47
+ "dependencies": {
48
+ "chalk": "^5.6.2",
49
+ "commander": "^12.1.0",
50
+ "dotenv": "^17.2.3",
51
+ "inquirer": "^9.2.23",
52
+ "ora": "^8.0.1"
53
+ },
54
+ "devDependencies": {
55
+ "@biomejs/biome": "^2.3.11"
56
+ }
57
+ }
package/src/config.js ADDED
@@ -0,0 +1,15 @@
1
+ const dotenv = require("dotenv");
2
+
3
+ dotenv.config();
4
+
5
+ function getEnvConfig() {
6
+ return {
7
+ username:
8
+ process.env.GITHUB_USERNAME || process.env.GH_USERNAME || process.env.GITHUB_USER || "",
9
+ token: process.env.GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_PAT || "",
10
+ };
11
+ }
12
+
13
+ module.exports = {
14
+ getEnvConfig,
15
+ };
package/src/github.js ADDED
@@ -0,0 +1,81 @@
1
+ const { fetchWithRetry } = require("./retry");
2
+
3
+ const GITHUB_API_URL = "https://api.github.com";
4
+
5
+ function buildHeaders(token) {
6
+ return {
7
+ Authorization: `token ${token}`,
8
+ Accept: "application/vnd.github.v3+json",
9
+ };
10
+ }
11
+
12
+ async function fetchAuthenticatedUser(token, { maxRetries } = {}) {
13
+ const response = await fetchWithRetry(
14
+ () =>
15
+ fetch(`${GITHUB_API_URL}/user`, {
16
+ headers: buildHeaders(token),
17
+ }),
18
+ { maxRetries },
19
+ );
20
+
21
+ return response.json();
22
+ }
23
+
24
+ async function fetchAllStarredRepos({ token, perPage = 100, maxRetries, onProgress } = {}) {
25
+ const allRepos = [];
26
+ let page = 1;
27
+
28
+ while (true) {
29
+ const url = `${GITHUB_API_URL}/user/starred?per_page=${perPage}&page=${page}`;
30
+
31
+ const response = await fetchWithRetry(
32
+ () =>
33
+ fetch(url, {
34
+ headers: buildHeaders(token),
35
+ }),
36
+ { maxRetries },
37
+ );
38
+
39
+ const data = await response.json();
40
+
41
+ if (!Array.isArray(data) || data.length === 0) {
42
+ break;
43
+ }
44
+
45
+ const fullNames = data.map((repo) => repo.full_name).filter(Boolean);
46
+ allRepos.push(...fullNames);
47
+
48
+ if (typeof onProgress === "function") {
49
+ onProgress(allRepos.length, page);
50
+ }
51
+
52
+ page += 1;
53
+ }
54
+
55
+ return allRepos;
56
+ }
57
+
58
+ async function unstarRepository(fullRepoName, { token, maxRetries } = {}) {
59
+ const url = `${GITHUB_API_URL}/user/starred/${fullRepoName}`;
60
+
61
+ try {
62
+ const response = await fetchWithRetry(
63
+ () =>
64
+ fetch(url, {
65
+ method: "DELETE",
66
+ headers: buildHeaders(token),
67
+ }),
68
+ { maxRetries },
69
+ );
70
+
71
+ return response.status === 204;
72
+ } catch (_error) {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ module.exports = {
78
+ fetchAuthenticatedUser,
79
+ fetchAllStarredRepos,
80
+ unstarRepository,
81
+ };
package/src/index.js ADDED
@@ -0,0 +1,272 @@
1
+ const { program } = require("commander");
2
+ const { getEnvConfig } = require("./config");
3
+ const { fetchAuthenticatedUser, fetchAllStarredRepos, unstarRepository } = require("./github");
4
+ const { createSpinner, sleep, formatNumber } = require("./ui");
5
+
6
+ let inquirerInstance;
7
+ async function getInquirer() {
8
+ if (inquirerInstance) {
9
+ return inquirerInstance;
10
+ }
11
+
12
+ const inquirerModule = await import("inquirer");
13
+ inquirerInstance = inquirerModule.default || inquirerModule;
14
+ return inquirerInstance;
15
+ }
16
+
17
+ let chalkInstance;
18
+ async function getChalk() {
19
+ if (chalkInstance) {
20
+ return chalkInstance;
21
+ }
22
+
23
+ const chalkModule = await import("chalk");
24
+ chalkInstance = chalkModule.default || chalkModule;
25
+ return chalkInstance;
26
+ }
27
+
28
+ function parsePositiveInt(value, fallback) {
29
+ const parsed = Number.parseInt(value, 10);
30
+ if (Number.isFinite(parsed) && parsed >= 0) {
31
+ return parsed;
32
+ }
33
+
34
+ return fallback;
35
+ }
36
+
37
+ function buildTestRepos(count) {
38
+ return Array.from({ length: count }, (_, index) => {
39
+ const repoNumber = String(index + 1).padStart(3, "0");
40
+ return `test-owner/repo-${repoNumber}`;
41
+ });
42
+ }
43
+
44
+ program
45
+ .name("gh-unstar")
46
+ .description("Remove all GitHub stars from your account")
47
+ .option("-u, --username <username>", "GitHub username")
48
+ .option("-t, --token <token>", "GitHub personal access token (classic)")
49
+ .option("-y, --yes", "Skip confirmation safety prompt")
50
+ .option("--dry-run", "Fetch and display count only")
51
+ .option(
52
+ "--delay <ms>",
53
+ "Delay between unstar requests (milliseconds)",
54
+ (value) => parsePositiveInt(value, 100),
55
+ 100,
56
+ )
57
+ .option(
58
+ "--max-retries <n>",
59
+ "Max retries for failed requests",
60
+ (value) => parsePositiveInt(value, 5),
61
+ 5,
62
+ )
63
+ .option(
64
+ "--per-page <n>",
65
+ "Results per page (max 100)",
66
+ (value) => parsePositiveInt(value, 100),
67
+ 100,
68
+ )
69
+ .option("--test", "Run in test mode (no GitHub API calls)")
70
+ .option(
71
+ "--test-count <n>",
72
+ "Number of fake starred repos in test mode",
73
+ (value) => parsePositiveInt(value, 25),
74
+ 25,
75
+ )
76
+ .option(
77
+ "--test-fail-every <n>",
78
+ "In test mode, every Nth unstar fails (0 disables)",
79
+ (value) => parsePositiveInt(value, 0),
80
+ 0,
81
+ );
82
+
83
+ async function resolveToken(options, env) {
84
+ let token = options.token || env.token;
85
+
86
+ if (!token) {
87
+ const inquirer = await getInquirer();
88
+ const answers = await inquirer.prompt([
89
+ {
90
+ type: "password",
91
+ name: "token",
92
+ message: "GitHub Personal Access Token (classic)",
93
+ mask: "*",
94
+ validate: (value) => (value && value.trim().length > 0 ? true : "Token is required."),
95
+ },
96
+ ]);
97
+
98
+ token = answers.token;
99
+ }
100
+
101
+ return token;
102
+ }
103
+
104
+ async function resolveUsername(options, env, token) {
105
+ let username = options.username || env.username;
106
+
107
+ if (!username) {
108
+ try {
109
+ const user = await fetchAuthenticatedUser(token, {
110
+ maxRetries: options.maxRetries,
111
+ });
112
+ if (user?.login) {
113
+ username = user.login;
114
+ }
115
+ } catch (_error) {
116
+ username = "";
117
+ }
118
+ }
119
+
120
+ if (!username) {
121
+ const inquirer = await getInquirer();
122
+ const answers = await inquirer.prompt([
123
+ {
124
+ type: "input",
125
+ name: "username",
126
+ message: "GitHub username",
127
+ validate: (value) => (value && value.trim().length > 0 ? true : "Username is required."),
128
+ },
129
+ ]);
130
+
131
+ username = answers.username;
132
+ }
133
+
134
+ return username;
135
+ }
136
+
137
+ async function confirmDestructive(username) {
138
+ const inquirer = await getInquirer();
139
+ const answers = await inquirer.prompt([
140
+ {
141
+ type: "input",
142
+ name: "confirmation",
143
+ message: `!!! WARNING !!! This will unstar ALL repos for user ${username}. Type 'YES' to proceed:`,
144
+ validate: (value) => (value.trim() === "YES" ? true : "Type 'YES' to continue."),
145
+ },
146
+ ]);
147
+
148
+ return answers.confirmation.trim() === "YES";
149
+ }
150
+
151
+ async function run() {
152
+ program.parse(process.argv);
153
+ const options = program.opts();
154
+ const env = getEnvConfig();
155
+ const chalk = await getChalk();
156
+
157
+ console.log(chalk.cyan("---------------------------------------------------------"));
158
+ console.log(chalk.cyan(" GitHub Star Clean-up CLI "));
159
+ console.log(chalk.cyan("---------------------------------------------------------"));
160
+
161
+ const isTestMode = Boolean(options.test);
162
+ let token = "";
163
+ let username = "";
164
+
165
+ if (isTestMode) {
166
+ token = options.token || env.token || "test-token";
167
+ username = options.username || env.username || "test-user";
168
+ console.log(chalk.yellow("Test mode enabled. No GitHub API calls will be made."));
169
+ } else {
170
+ token = await resolveToken(options, env);
171
+ username = await resolveUsername(options, env, token);
172
+ }
173
+
174
+ console.log(`\nPreparing to unstar repositories for ${chalk.bold(username)}.`);
175
+ const fetchSpinner = (
176
+ await createSpinner(
177
+ isTestMode
178
+ ? "Fetching starred repositories (test mode)..."
179
+ : "Fetching starred repositories...",
180
+ )
181
+ ).start();
182
+ let starredRepos = [];
183
+
184
+ try {
185
+ if (isTestMode) {
186
+ starredRepos = buildTestRepos(options.testCount);
187
+ fetchSpinner.succeed(
188
+ `Fetched ${formatNumber(starredRepos.length)} repositories (test mode).`,
189
+ );
190
+ } else {
191
+ starredRepos = await fetchAllStarredRepos({
192
+ token,
193
+ perPage: Math.min(options.perPage, 100),
194
+ maxRetries: options.maxRetries,
195
+ onProgress: (count, page) => {
196
+ fetchSpinner.text = `Fetched ${formatNumber(count)} repositories (Page ${page})`;
197
+ },
198
+ });
199
+ fetchSpinner.succeed(`Fetched ${formatNumber(starredRepos.length)} repositories.`);
200
+ }
201
+ } catch (error) {
202
+ fetchSpinner.fail(`Failed to fetch starred repositories: ${error.message}`);
203
+ return;
204
+ }
205
+
206
+ if (starredRepos.length === 0) {
207
+ console.log(chalk.green("No starred repositories found. Clean slate achieved!"));
208
+ return;
209
+ }
210
+
211
+ console.log(chalk.yellow(`Found ${formatNumber(starredRepos.length)} starred repositories.`));
212
+
213
+ if (options.dryRun) {
214
+ console.log(chalk.gray("Dry run enabled. No changes were made."));
215
+ return;
216
+ }
217
+
218
+ if (!options.yes && !isTestMode) {
219
+ const confirmed = await confirmDestructive(username);
220
+ if (!confirmed) {
221
+ console.log(chalk.gray("Unstar process cancelled by user."));
222
+ return;
223
+ }
224
+ }
225
+ console.log(chalk.cyan("\n--- Starting Unstarring Process ---"));
226
+
227
+ const unstarSpinner = (await createSpinner("Unstarring repositories...")).start();
228
+
229
+ let unstarredCount = 0;
230
+ const failedRepos = [];
231
+
232
+ for (let i = 0; i < starredRepos.length; i += 1) {
233
+ const repo = starredRepos[i];
234
+ unstarSpinner.text = `(${i + 1}/${starredRepos.length}) Unstarring ${repo}`;
235
+
236
+ let success = false;
237
+
238
+ if (isTestMode) {
239
+ const failEvery = options.testFailEvery || 0;
240
+ success = failEvery > 0 ? (i + 1) % failEvery !== 0 : true;
241
+ } else {
242
+ success = await unstarRepository(repo, {
243
+ token,
244
+ maxRetries: options.maxRetries,
245
+ });
246
+ }
247
+
248
+ if (success) {
249
+ unstarredCount += 1;
250
+ } else {
251
+ failedRepos.push(repo);
252
+ }
253
+
254
+ await sleep(options.delay);
255
+ }
256
+
257
+ unstarSpinner.succeed("Unstarring complete.");
258
+
259
+ console.log(chalk.cyan("\n--- Process Complete ---"));
260
+ console.log(`Total repositories processed: ${starredRepos.length}`);
261
+ console.log(chalk.green(`Successfully unstarred: ${unstarredCount}`));
262
+
263
+ if (failedRepos.length > 0) {
264
+ console.log(chalk.red(`Failed to unstar: ${failedRepos.length}`));
265
+ }
266
+ }
267
+
268
+ module.exports = run;
269
+
270
+ if (require.main === module) {
271
+ run();
272
+ }
package/src/retry.js ADDED
@@ -0,0 +1,49 @@
1
+ const DEFAULT_MAX_RETRIES = 5;
2
+
3
+ async function fetchWithRetry(apiCall, { maxRetries = DEFAULT_MAX_RETRIES } = {}) {
4
+ let retries = 0;
5
+
6
+ while (true) {
7
+ try {
8
+ const response = await apiCall();
9
+
10
+ if (response.status === 403 && response.headers.get("x-ratelimit-remaining") === "0") {
11
+ const resetTime = Number.parseInt(response.headers.get("x-ratelimit-reset"), 10) * 1000;
12
+ const delay = resetTime - Date.now();
13
+ const waitMs = delay > 0 ? delay : 1000;
14
+ console.warn("\n\n!!! GitHub Rate Limit Hit !!!");
15
+ console.log(
16
+ `Waiting ${Math.ceil(waitMs / 1000)} seconds until reset (${new Date(
17
+ resetTime,
18
+ ).toLocaleTimeString()}).`,
19
+ );
20
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
21
+ continue;
22
+ }
23
+
24
+ if (response.status >= 400 && response.status !== 204) {
25
+ const errorBody = await response.json().catch(() => ({
26
+ message: "No content",
27
+ }));
28
+ throw new Error(
29
+ `API returned status ${response.status}: ${errorBody.message || "Unknown error"}`,
30
+ );
31
+ }
32
+
33
+ return response;
34
+ } catch (error) {
35
+ if (retries < maxRetries) {
36
+ const delay = 2 ** retries * 1000;
37
+ await new Promise((resolve) => setTimeout(resolve, delay));
38
+ retries += 1;
39
+ continue;
40
+ }
41
+
42
+ throw new Error(`Failed after ${maxRetries} attempts. Last error: ${error.message}`);
43
+ }
44
+ }
45
+ }
46
+
47
+ module.exports = {
48
+ fetchWithRetry,
49
+ };
package/src/ui.js ADDED
@@ -0,0 +1,34 @@
1
+ let oraInstance;
2
+
3
+ async function getOra() {
4
+ if (oraInstance) {
5
+ return oraInstance;
6
+ }
7
+
8
+ const oraModule = await import("ora");
9
+ oraInstance = oraModule.default || oraModule;
10
+ return oraInstance;
11
+ }
12
+
13
+ function sleep(ms) {
14
+ if (!Number.isFinite(ms) || ms <= 0) {
15
+ return Promise.resolve();
16
+ }
17
+
18
+ return new Promise((resolve) => setTimeout(resolve, ms));
19
+ }
20
+
21
+ function formatNumber(value) {
22
+ return new Intl.NumberFormat("en-US").format(value);
23
+ }
24
+
25
+ async function createSpinner(text) {
26
+ const ora = await getOra();
27
+ return ora({ text, color: "cyan" });
28
+ }
29
+
30
+ module.exports = {
31
+ createSpinner,
32
+ sleep,
33
+ formatNumber,
34
+ };