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 +3 -0
- package/LICENSE +21 -0
- package/README.md +95 -0
- package/bin/gh-unstar.js +5 -0
- package/package.json +57 -0
- package/src/config.js +15 -0
- package/src/github.js +81 -0
- package/src/index.js +272 -0
- package/src/retry.js +49 -0
- package/src/ui.js +34 -0
package/.env.example
ADDED
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
|
+
[](https://www.npmjs.com/package/gh-unstar)
|
|
6
|
+
[](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
|
package/bin/gh-unstar.js
ADDED
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
|
+
};
|