git-push-time 1.0.3 → 1.1.1
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/.github/workflows/npm-publish.yml +33 -0
- package/README.md +215 -134
- package/bin/index.js +337 -1004
- package/lib/commands/edit-time.js +141 -0
- package/lib/commands/fix.js +113 -0
- package/lib/commands/github.js +439 -0
- package/lib/commands/pull.js +89 -0
- package/lib/commands/push.js +673 -0
- package/lib/config.js +220 -0
- package/lib/fix.js +286 -260
- package/lib/git.js +492 -452
- package/lib/github.js +322 -0
- package/lib/prompt-helpers.js +62 -0
- package/lib/secrets.js +92 -87
- package/lib/ui.js +804 -728
- package/lib/utils.js +120 -106
- package/package.json +35 -32
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import {
|
|
3
|
+
checkGitInstalled,
|
|
4
|
+
isGitRepo,
|
|
5
|
+
getCurrentBranch,
|
|
6
|
+
getRecentCommits,
|
|
7
|
+
rewriteCommitDate,
|
|
8
|
+
resolveTargetDir
|
|
9
|
+
} from '../git.js';
|
|
10
|
+
import { parseCustomDate } from '../utils.js';
|
|
11
|
+
import { selectWithBack, inputWithBack, BACK } from '../prompt-helpers.js';
|
|
12
|
+
import {
|
|
13
|
+
printSection,
|
|
14
|
+
printTargetDir,
|
|
15
|
+
printGitMissing,
|
|
16
|
+
printErrorBox
|
|
17
|
+
} from '../ui.js';
|
|
18
|
+
|
|
19
|
+
export async function runEditCommitTimeCommand(options = {}) {
|
|
20
|
+
const fromMenu = options.fromMenu === true;
|
|
21
|
+
|
|
22
|
+
let targetDir = process.cwd();
|
|
23
|
+
if (options.dir) {
|
|
24
|
+
const r = resolveTargetDir(options.dir, process.cwd());
|
|
25
|
+
if (!r.ok) {
|
|
26
|
+
printErrorBox('THƯ MỤC KHÔNG HỢP LỆ', [r.error]);
|
|
27
|
+
if (fromMenu) return;
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
targetDir = r.fullPath;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!checkGitInstalled()) {
|
|
34
|
+
printGitMissing();
|
|
35
|
+
if (fromMenu) return;
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
if (!isGitRepo(targetDir)) {
|
|
39
|
+
printErrorBox('CHƯA PHẢI GIT REPOSITORY', [`Thư mục '${targetDir}' chưa phải là Git repository.`]);
|
|
40
|
+
if (fromMenu) return;
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
printSection('SỬA THỜI GIAN COMMIT (BACKDATE)');
|
|
45
|
+
printTargetDir(targetDir);
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const commits = getRecentCommits(100, targetDir);
|
|
49
|
+
if (commits.length === 0) {
|
|
50
|
+
printErrorBox('CHƯA CÓ COMMIT', ['Repository chưa có commit nào.']);
|
|
51
|
+
if (fromMenu) return;
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Dùng loop thay vì đệ quy để tránh stack tăng khi Back nhiều lần
|
|
56
|
+
let commit = null;
|
|
57
|
+
let dateInput = null;
|
|
58
|
+
while (true) {
|
|
59
|
+
// Bước 1: Chọn commit
|
|
60
|
+
if (!commit) {
|
|
61
|
+
if (commits.length === 1) {
|
|
62
|
+
commit = commits[0];
|
|
63
|
+
console.log(chalk.hex('#94A3B8')(` » Chỉ có 1 commit nên tự chọn: ${commit.shortHash} — ${commit.subject}\n`));
|
|
64
|
+
} else {
|
|
65
|
+
const picked = await selectWithBack({
|
|
66
|
+
message: `Chọn commit cần đổi thời gian (${commits.length} commit gần nhất):`,
|
|
67
|
+
pageSize: 12,
|
|
68
|
+
choices: commits.map((item) => ({
|
|
69
|
+
name: `${item.date} ${item.shortHash} ${item.subject}`,
|
|
70
|
+
value: item,
|
|
71
|
+
description: 'Sửa commit này sẽ viết lại nó và các commit phía sau'
|
|
72
|
+
}))
|
|
73
|
+
});
|
|
74
|
+
if (picked === BACK) return;
|
|
75
|
+
commit = picked;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Bước 2: Chọn kiểu thời gian
|
|
80
|
+
const dateOption = await selectWithBack({
|
|
81
|
+
message: 'Chọn thời gian mới:',
|
|
82
|
+
choices: [
|
|
83
|
+
{ name: 'Ngay bây giờ', value: 'now', description: 'Dùng thời gian hiện tại' },
|
|
84
|
+
{ name: 'Nhập thời gian tương đối', value: 'relative', description: 'VD: -2d, -5h, -30m, -3M' },
|
|
85
|
+
{ name: 'Nhập ngày giờ cụ thể', value: 'custom', description: 'VD: 2024-01-15 14:30:00' }
|
|
86
|
+
]
|
|
87
|
+
});
|
|
88
|
+
if (dateOption === BACK) {
|
|
89
|
+
commit = null; // quay về chọn commit lại
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
dateInput = dateOption;
|
|
94
|
+
if (dateOption === 'relative') {
|
|
95
|
+
const val = await inputWithBack({
|
|
96
|
+
message: 'Nhập thời gian (ví dụ: -2d, -5h, -30m, -3M):',
|
|
97
|
+
default: '-1d',
|
|
98
|
+
validate: (value) => {
|
|
99
|
+
const parsed = parseCustomDate(value);
|
|
100
|
+
return parsed.valid || parsed.error;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
if (val === BACK) continue; // về chọn kiểu thời gian
|
|
104
|
+
dateInput = val;
|
|
105
|
+
} else if (dateOption === 'custom') {
|
|
106
|
+
const val = await inputWithBack({
|
|
107
|
+
message: 'Nhập ngày giờ (YYYY-MM-DD HH:mm:ss):',
|
|
108
|
+
validate: (value) => {
|
|
109
|
+
const parsed = parseCustomDate(value);
|
|
110
|
+
return parsed.valid || parsed.error;
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
if (val === BACK) continue; // về chọn kiểu thời gian
|
|
114
|
+
dateInput = val;
|
|
115
|
+
}
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const parsed = parseCustomDate(dateInput);
|
|
120
|
+
if (!parsed.valid) {
|
|
121
|
+
printErrorBox('THỜI GIAN KHÔNG HỢP LỆ', [parsed.error], 'VD: now, -2d, -5h, 2024-01-15 14:30:00');
|
|
122
|
+
if (fromMenu) return;
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const currentBranch = getCurrentBranch(targetDir);
|
|
127
|
+
const result = rewriteCommitDate(commit.hash, parsed.formatted, targetDir);
|
|
128
|
+
|
|
129
|
+
printSection('ĐÃ ĐỔI THỜI GIAN COMMIT THÀNH CÔNG');
|
|
130
|
+
console.log(` Nhánh: ${chalk.hex('#38BDF8').bold(currentBranch)}`);
|
|
131
|
+
console.log(` Commit: ${chalk.hex('#FBBF24').bold(commit.shortHash)} — ${commit.subject}`);
|
|
132
|
+
console.log(` Thời gian mới: ${chalk.hex('#C084FC').bold(parsed.display)}`);
|
|
133
|
+
console.log(` Đã viết lại ${result.rewrittenCount} commit trên branch hiện tại.`);
|
|
134
|
+
console.log(` Nhánh backup an toàn: ${chalk.hex('#6EE7B7')(result.backupBranch)}`);
|
|
135
|
+
console.log(chalk.hex('#FBBF24')(' [!] Nếu commit đã từng push, bạn cần dùng force push (--force) để cập nhật remote.\n'));
|
|
136
|
+
} catch (error) {
|
|
137
|
+
printErrorBox('KHÔNG THỂ ĐỔI THỜI GIAN', [error.message], 'Working tree phải sạch và commit phải nằm trên branch hiện tại.');
|
|
138
|
+
if (fromMenu) return;
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import {
|
|
4
|
+
checkGitInstalled,
|
|
5
|
+
isGitRepo,
|
|
6
|
+
getCurrentBranchStrict,
|
|
7
|
+
findRiskyStagedFiles,
|
|
8
|
+
resolveTargetDir
|
|
9
|
+
} from '../git.js';
|
|
10
|
+
import { scanStagedContentForSecrets } from '../secrets.js';
|
|
11
|
+
import {
|
|
12
|
+
autoFixStagedSecrets,
|
|
13
|
+
findRiskyWorkingTreeFiles,
|
|
14
|
+
getUnpushedCommits,
|
|
15
|
+
squashUnpushedCommits
|
|
16
|
+
} from '../fix.js';
|
|
17
|
+
import { selectWithBack, BACK } from '../prompt-helpers.js';
|
|
18
|
+
import {
|
|
19
|
+
printSection,
|
|
20
|
+
printTargetDir,
|
|
21
|
+
printGitMissing,
|
|
22
|
+
printErrorBox,
|
|
23
|
+
printPrePushSecretWarning,
|
|
24
|
+
printFixResult
|
|
25
|
+
} from '../ui.js';
|
|
26
|
+
|
|
27
|
+
export async function runFixCommand(options = {}) {
|
|
28
|
+
const fromMenu = options.fromMenu === true;
|
|
29
|
+
|
|
30
|
+
if (!checkGitInstalled()) {
|
|
31
|
+
printGitMissing();
|
|
32
|
+
if (fromMenu) return;
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let targetDir = process.cwd();
|
|
37
|
+
if (options.dir) {
|
|
38
|
+
const r = resolveTargetDir(options.dir, process.cwd());
|
|
39
|
+
if (!r.ok) {
|
|
40
|
+
printErrorBox('THƯ MỤC KHÔNG HỢP LỆ', [r.error]);
|
|
41
|
+
if (fromMenu) return;
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
targetDir = r.fullPath;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
printSection('QUÉT & GỠ SECRET BẢO VỆ REPO');
|
|
48
|
+
printTargetDir(targetDir);
|
|
49
|
+
|
|
50
|
+
if (!isGitRepo(targetDir)) {
|
|
51
|
+
printErrorBox('CHƯA PHẢI GIT REPOSITORY', [`Thư mục '${targetDir}' chưa phải là Git repository.`]);
|
|
52
|
+
if (fromMenu) return;
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const spinner = ora({ text: 'Đang kiểm tra staged files và thư mục làm việc…', color: 'cyan' }).start();
|
|
57
|
+
const risky = findRiskyStagedFiles(targetDir);
|
|
58
|
+
const findings = scanStagedContentForSecrets(targetDir);
|
|
59
|
+
const riskyWorking = findRiskyWorkingTreeFiles(targetDir);
|
|
60
|
+
spinner.stop();
|
|
61
|
+
|
|
62
|
+
const allDetected = [...new Set([...risky, ...findings.map((f) => f.file), ...riskyWorking])];
|
|
63
|
+
const hadSecrets = allDetected.length > 0;
|
|
64
|
+
|
|
65
|
+
if (allDetected.length === 0) {
|
|
66
|
+
console.log(chalk.hex('#34D399')(' [OK] Không phát hiện file secret mới ở stage hay thư mục làm việc!\n'));
|
|
67
|
+
} else {
|
|
68
|
+
printPrePushSecretWarning({ riskyFiles: allDetected, findings });
|
|
69
|
+
spinner.start('Đang tự động fix (gỡ khỏi stage + thêm .gitignore)...');
|
|
70
|
+
const res = autoFixStagedSecrets(allDetected, targetDir);
|
|
71
|
+
spinner.succeed(chalk.hex('#34D399')(`[OK] Đã xử lý xong ${res.fixed.length} file!`));
|
|
72
|
+
printFixResult(res);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Kiểm tra unpushed commits — chỉ gợi ý squash khi có dấu hiệu secret,
|
|
76
|
+
// tránh viết lại lịch sử sạch không cần thiết.
|
|
77
|
+
const currentBranch = getCurrentBranchStrict(targetDir) || 'main';
|
|
78
|
+
const unpushed = getUnpushedCommits(currentBranch, targetDir);
|
|
79
|
+
if (unpushed.length > 0 && hadSecrets) {
|
|
80
|
+
printSection(`${unpushed.length} COMMIT CHƯA PUSH TRÊN LOCAL`);
|
|
81
|
+
unpushed.slice(0, 5).forEach((c) => console.log(chalk.hex('#94A3B8')(` - ${c}`)));
|
|
82
|
+
if (unpushed.length > 5) {
|
|
83
|
+
console.log(chalk.hex('#64748B')(` ...và ${unpushed.length - 5} commit khác`));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let sqChoice = 'no';
|
|
87
|
+
if (options.yes) {
|
|
88
|
+
sqChoice = 'no';
|
|
89
|
+
} else {
|
|
90
|
+
sqChoice = await selectWithBack({
|
|
91
|
+
message: `Gộp (squash) ${unpushed.length} commit thành 1 commit sạch để xóa vết secret cũ trong lịch sử?`,
|
|
92
|
+
choices: [
|
|
93
|
+
{ name: 'Có, gộp thành 1 commit sạch', value: 'yes', description: 'Làm sạch hoàn toàn lịch sử commit' },
|
|
94
|
+
{ name: 'Không, giữ nguyên', value: 'no', description: 'Bỏ qua' }
|
|
95
|
+
]
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (sqChoice !== BACK && sqChoice === 'yes') {
|
|
100
|
+
spinner.start('Đang làm sạch lịch sử commit chưa push...');
|
|
101
|
+
const sq = squashUnpushedCommits(currentBranch, 'Clean commit without secrets', null, targetDir);
|
|
102
|
+
if (sq.ok) {
|
|
103
|
+
spinner.succeed(chalk.hex('#34D399')('[OK] Đã làm sạch commit chưa push! Sẵn sàng push lên remote an toàn.'));
|
|
104
|
+
} else {
|
|
105
|
+
spinner.fail(chalk.red(`Không thể squash: ${sq.error}`));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} else if (unpushed.length > 0) {
|
|
109
|
+
console.log(chalk.hex('#64748B')(` » Có ${unpushed.length} commit chưa push (lịch sử sạch, không cần squash).\n`));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
console.log(chalk.hex('#34D399')(`\n [OK] Quét hoàn tất trên branch ${currentBranch} — repo an toàn.\n`));
|
|
113
|
+
}
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { select } from '@inquirer/prompts';
|
|
5
|
+
import {
|
|
6
|
+
getCurrentGitHubUser,
|
|
7
|
+
verifyAndFetchUser,
|
|
8
|
+
listUserRepos,
|
|
9
|
+
createGitHubRepo,
|
|
10
|
+
initiateDeviceFlow,
|
|
11
|
+
pollDeviceToken,
|
|
12
|
+
openBrowser
|
|
13
|
+
} from '../github.js';
|
|
14
|
+
import {
|
|
15
|
+
saveGitHubToken,
|
|
16
|
+
removeGitHubToken,
|
|
17
|
+
getAllGitHubAccounts,
|
|
18
|
+
setActiveGitHubAccount
|
|
19
|
+
} from '../config.js';
|
|
20
|
+
import { selectWithBack, inputWithBack, BACK } from '../prompt-helpers.js';
|
|
21
|
+
import {
|
|
22
|
+
printSection,
|
|
23
|
+
printGitHubProfile,
|
|
24
|
+
printDeviceCodePrompt,
|
|
25
|
+
printRepoCreatedBox,
|
|
26
|
+
printErrorBox,
|
|
27
|
+
printCancelled
|
|
28
|
+
} from '../ui.js';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Hiển thị thông tin tài khoản GitHub hiện tại
|
|
32
|
+
*/
|
|
33
|
+
export async function showGitHubStatus() {
|
|
34
|
+
const spinner = ora({ text: 'Đang kiểm tra kết nối GitHub…', color: 'cyan' }).start();
|
|
35
|
+
const info = await getCurrentGitHubUser();
|
|
36
|
+
spinner.stop();
|
|
37
|
+
|
|
38
|
+
if (!info.authenticated) {
|
|
39
|
+
printSection('TRẠNG THÁI GITHUB');
|
|
40
|
+
console.log(chalk.hex('#F59E0B')(' [!] Chưa kết nối tài khoản GitHub.'));
|
|
41
|
+
console.log(chalk.hex('#94A3B8')(' Chạy "git-push-time github login" để kết nối tài khoản qua trình duyệt.\n'));
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
printGitHubProfile(info.user, info.source);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Đăng nhập GitHub qua OAuth Device Flow
|
|
51
|
+
*/
|
|
52
|
+
async function loginViaDeviceFlow() {
|
|
53
|
+
const initSpinner = ora({ text: 'Đang kết nối GitHub OAuth…', color: 'cyan' }).start();
|
|
54
|
+
const init = await initiateDeviceFlow();
|
|
55
|
+
initSpinner.stop();
|
|
56
|
+
|
|
57
|
+
if (!init.ok || !init.data) {
|
|
58
|
+
printErrorBox('KẾT NỐI OAUTH THẤT BẠI', [init.error || 'Không nhận được mã xác thực từ GitHub.']);
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const { device_code, user_code, verification_uri, interval, expires_in } = init.data;
|
|
63
|
+
|
|
64
|
+
// Hiển thị box mã xác thực cho user
|
|
65
|
+
printDeviceCodePrompt({ userCode: user_code, verificationUri: verification_uri });
|
|
66
|
+
|
|
67
|
+
// Tự động mở browser
|
|
68
|
+
openBrowser(verification_uri);
|
|
69
|
+
|
|
70
|
+
const pollSpinner = ora({
|
|
71
|
+
text: chalk.hex('#38BDF8')('Đang chờ bạn xác nhận và nhấn [Authorize] trên trình duyệt…'),
|
|
72
|
+
color: 'cyan'
|
|
73
|
+
}).start();
|
|
74
|
+
|
|
75
|
+
const pollResult = await pollDeviceToken(device_code, interval, expires_in, (status) => {
|
|
76
|
+
if (status === 'slow_down') {
|
|
77
|
+
pollSpinner.text = chalk.hex('#F59E0B')('GitHub yêu cầu giãn cách kết nối, đang tiếp tục chờ bạn xác nhận…');
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (!pollResult.ok || !pollResult.token) {
|
|
82
|
+
pollSpinner.fail(chalk.red('Xác thực thất bại!'));
|
|
83
|
+
printErrorBox('ĐĂNG NHẬP THẤT BẠI', [pollResult.error]);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
pollSpinner.text = 'Đang tải thông tin tài khoản…';
|
|
88
|
+
const userRes = await verifyAndFetchUser(pollResult.token);
|
|
89
|
+
if (!userRes.ok) {
|
|
90
|
+
pollSpinner.fail(chalk.red('Không thể tải profile GitHub!'));
|
|
91
|
+
printErrorBox('LỖI PROFILE', [userRes.error]);
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
saveGitHubToken(pollResult.token, userRes.user);
|
|
96
|
+
pollSpinner.succeed(chalk.hex('#34D399').bold(`Đăng nhập thành công! Xin chào @${userRes.user.login}`));
|
|
97
|
+
printGitHubProfile(userRes.user, 'oauth-device');
|
|
98
|
+
return userRes.user;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Đăng nhập GitHub bằng Personal Access Token (PAT)
|
|
103
|
+
*/
|
|
104
|
+
async function loginViaToken() {
|
|
105
|
+
printSection('ĐĂNG NHẬP BẰNG PERSONAL ACCESS TOKEN');
|
|
106
|
+
console.log(chalk.hex('#38BDF8')(' » Hướng dẫn lấy mã Token:'));
|
|
107
|
+
console.log(chalk.hex('#94A3B8')(' 1. Mở: https://github.com/settings/tokens'));
|
|
108
|
+
console.log(chalk.hex('#94A3B8')(' 2. Chọn "Generate new token (classic)"'));
|
|
109
|
+
console.log(chalk.hex('#94A3B8')(' 3. Tích chọn quyền: ') + chalk.hex('#34D399').bold('repo') + chalk.hex('#94A3B8')(' (Full control of repositories)'));
|
|
110
|
+
console.log(chalk.hex('#94A3B8')(' 4. Copy mã token (bắt đầu bằng ghp_ hoặc github_pat_) và dán vào bên dưới.\n'));
|
|
111
|
+
|
|
112
|
+
const tokenInput = await inputWithBack({
|
|
113
|
+
message: 'Dán GitHub Token của bạn:',
|
|
114
|
+
validate: (val) => {
|
|
115
|
+
if (!val || !val.trim()) return 'Vui lòng không để trống token!';
|
|
116
|
+
if (val.trim().length < 15) return 'Mã token dường như quá ngắn. Vui lòng kiểm tra lại.';
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
if (tokenInput === BACK) return null;
|
|
122
|
+
|
|
123
|
+
const spinner = ora({ text: 'Đang xác thực token với GitHub…', color: 'cyan' }).start();
|
|
124
|
+
const res = await verifyAndFetchUser(tokenInput.trim());
|
|
125
|
+
if (!res.ok) {
|
|
126
|
+
spinner.fail(chalk.red('Xác thực thất bại!'));
|
|
127
|
+
printErrorBox('TOKEN KHÔNG HỢP LỆ', [res.error], 'Kiểm tra token còn hạn và có quyền "repo" không.');
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
saveGitHubToken(tokenInput.trim(), res.user);
|
|
132
|
+
spinner.succeed(chalk.hex('#34D399')(`Đăng nhập thành công! Xin chào @${res.user.login}`));
|
|
133
|
+
printGitHubProfile(res.user, 'config');
|
|
134
|
+
return res.user;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Menu đăng nhập GitHub linh hoạt (OAuth Device Flow hoặc Token)
|
|
139
|
+
*/
|
|
140
|
+
export async function promptGitHubLogin() {
|
|
141
|
+
printSection('ĐĂNG NHẬP TÀI KHOẢN GITHUB');
|
|
142
|
+
|
|
143
|
+
const method = await selectWithBack({
|
|
144
|
+
message: 'Chọn phương thức đăng nhập:',
|
|
145
|
+
choices: [
|
|
146
|
+
{
|
|
147
|
+
name: '[OAuth] Mở trình duyệt xác nhận (Device Flow) — Khuyên dùng',
|
|
148
|
+
value: 'oauth',
|
|
149
|
+
description: 'Tự động mở trình duyệt, nhập mã xác thực không cần copy token'
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
name: '[Token] Dán Personal Access Token (PAT)',
|
|
153
|
+
value: 'token',
|
|
154
|
+
description: 'Nhập token bảo mật tự tạo trên github.com/settings/tokens'
|
|
155
|
+
}
|
|
156
|
+
]
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
if (method === BACK) return null;
|
|
160
|
+
if (method === 'oauth') return await loginViaDeviceFlow();
|
|
161
|
+
if (method === 'token') return await loginViaToken();
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Bước chọn GitHub Account trong luồng Wizard Push
|
|
167
|
+
* @returns {Promise<{ useGitHub: boolean, user?: any, back?: boolean }>}
|
|
168
|
+
*/
|
|
169
|
+
export async function promptSelectGitHubAccount() {
|
|
170
|
+
printSection('CHỌN TÀI KHOẢN GITHUB');
|
|
171
|
+
|
|
172
|
+
const accounts = getAllGitHubAccounts();
|
|
173
|
+
const currentStatus = await getCurrentGitHubUser();
|
|
174
|
+
|
|
175
|
+
if (accounts.length === 0 && !currentStatus.authenticated) {
|
|
176
|
+
// Chưa có tài khoản nào
|
|
177
|
+
const loginChoice = await selectWithBack({
|
|
178
|
+
message: 'Bạn chưa kết nối tài khoản GitHub. Bạn muốn làm gì?',
|
|
179
|
+
choices: [
|
|
180
|
+
{
|
|
181
|
+
name: 'Đăng nhập GitHub ngay (Mở trình duyệt xác thực OAuth)',
|
|
182
|
+
value: 'login',
|
|
183
|
+
description: 'Kết nối tài khoản để chọn/tạo repo tự động'
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
name: 'Bỏ qua GitHub (Dán URL Git repository thủ công)',
|
|
187
|
+
value: 'skip',
|
|
188
|
+
description: 'Không dùng tài khoản GitHub, nhập link https://... trực tiếp'
|
|
189
|
+
}
|
|
190
|
+
]
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
if (loginChoice === BACK) return { back: true };
|
|
194
|
+
if (loginChoice === 'login') {
|
|
195
|
+
const user = await promptGitHubLogin();
|
|
196
|
+
if (user) {
|
|
197
|
+
return { useGitHub: true, user };
|
|
198
|
+
}
|
|
199
|
+
// Nếu đăng nhập hủy/thất bại, hỏi có muốn nhập URL thủ công không
|
|
200
|
+
return { useGitHub: false };
|
|
201
|
+
}
|
|
202
|
+
return { useGitHub: false };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Đã có tài khoản
|
|
206
|
+
const currentLogin = currentStatus.authenticated ? currentStatus.user.login : accounts[0]?.login;
|
|
207
|
+
const choices = [
|
|
208
|
+
{
|
|
209
|
+
name: `Tiếp tục với @${currentLogin} (Đang chọn)`,
|
|
210
|
+
value: `use:${currentLogin}`,
|
|
211
|
+
description: `Dùng tài khoản @${currentLogin} để liên kết repository`
|
|
212
|
+
}
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
// Danh sách các tài khoản khác
|
|
216
|
+
for (const acc of accounts) {
|
|
217
|
+
if (acc.login !== currentLogin) {
|
|
218
|
+
choices.push({
|
|
219
|
+
name: `Đổi sang @${acc.login}`,
|
|
220
|
+
value: `switch:${acc.login}`,
|
|
221
|
+
description: `Chuyển sang tài khoản @${acc.login}`
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
choices.push(
|
|
227
|
+
{
|
|
228
|
+
name: '+ Đăng nhập thêm tài khoản GitHub khác',
|
|
229
|
+
value: 'add_account',
|
|
230
|
+
description: 'Đăng nhập tài khoản mới bằng OAuth hoặc Token'
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: 'Bỏ qua GitHub (Dán URL repository thủ công)',
|
|
234
|
+
value: 'skip',
|
|
235
|
+
description: 'Nhập link repo bằng tay'
|
|
236
|
+
}
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
const picked = await selectWithBack({
|
|
240
|
+
message: 'Chọn tài khoản GitHub sẽ sử dụng:',
|
|
241
|
+
choices
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
if (picked === BACK) return { back: true };
|
|
245
|
+
if (picked === 'skip') return { useGitHub: false };
|
|
246
|
+
|
|
247
|
+
if (picked === 'add_account') {
|
|
248
|
+
const newUser = await promptGitHubLogin();
|
|
249
|
+
if (newUser) return { useGitHub: true, user: newUser };
|
|
250
|
+
return { useGitHub: true, user: currentStatus.user };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (picked.startsWith('switch:')) {
|
|
254
|
+
const targetLogin = picked.replace('switch:', '');
|
|
255
|
+
setActiveGitHubAccount(targetLogin);
|
|
256
|
+
const updated = await getCurrentGitHubUser();
|
|
257
|
+
console.log(chalk.hex('#34D399')(` [OK] Đã chuyển sang tài khoản @${targetLogin}\n`));
|
|
258
|
+
return { useGitHub: true, user: updated.user };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return { useGitHub: true, user: currentStatus.user };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Luồng tạo repository mới trên GitHub
|
|
266
|
+
* @param {object} options
|
|
267
|
+
* @param {string} options.targetDir Thư mục nguồn (dùng để gợi ý tên repo mặc định)
|
|
268
|
+
* @returns {Promise<{ ok: boolean, repo?: any, action?: 'push' | 'done', back?: boolean }>}
|
|
269
|
+
*/
|
|
270
|
+
export async function promptCreateRepo({ targetDir = process.cwd() } = {}) {
|
|
271
|
+
let currentUser = await getCurrentGitHubUser();
|
|
272
|
+
if (!currentUser.authenticated) {
|
|
273
|
+
console.log(chalk.hex('#F59E0B')('\n [!] Bạn cần kết nối tài khoản GitHub trước khi tạo repository.'));
|
|
274
|
+
const loginUser = await promptGitHubLogin();
|
|
275
|
+
if (!loginUser) {
|
|
276
|
+
return { ok: false, back: true };
|
|
277
|
+
}
|
|
278
|
+
currentUser = { authenticated: true, user: loginUser };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
printSection('TẠO REPOSITORY MỚI TRÊN GITHUB');
|
|
282
|
+
console.log(chalk.hex('#94A3B8')(` » Đang tạo trên tài khoản: ${chalk.hex('#38BDF8').bold('@' + currentUser.user.login)}`));
|
|
283
|
+
|
|
284
|
+
const folderName = path.basename(path.resolve(targetDir))
|
|
285
|
+
.toLowerCase()
|
|
286
|
+
.replace(/[^a-z0-9_.-]/g, '-');
|
|
287
|
+
|
|
288
|
+
const repoName = await inputWithBack({
|
|
289
|
+
message: 'Tên repository mới:',
|
|
290
|
+
default: folderName || 'my-awesome-repo',
|
|
291
|
+
validate: (val) => {
|
|
292
|
+
const trimmed = (val || '').trim();
|
|
293
|
+
if (!trimmed) return 'Tên repo không được để trống!';
|
|
294
|
+
if (!/^[a-zA-Z0-9_.-]+$/.test(trimmed)) {
|
|
295
|
+
return 'Tên repo chỉ chứa chữ, số, dấu gạch -, gạch dưới _ hoặc dấu chấm .';
|
|
296
|
+
}
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
if (repoName === BACK) return { ok: false, back: true };
|
|
302
|
+
|
|
303
|
+
const description = await inputWithBack({
|
|
304
|
+
message: 'Mô tả ngắn cho dự án (tùy chọn):',
|
|
305
|
+
default: ''
|
|
306
|
+
});
|
|
307
|
+
if (description === BACK) return { ok: false, back: true };
|
|
308
|
+
|
|
309
|
+
const visibility = await selectWithBack({
|
|
310
|
+
message: 'Quyền riêng tư của repository:',
|
|
311
|
+
choices: [
|
|
312
|
+
{ name: 'Public (Bất kỳ ai cũng có thể xem)', value: 'public', description: 'Mã nguồn mở công khai' },
|
|
313
|
+
{ name: 'Private (Chỉ bạn và người được phân quyền xem)', value: 'private', description: 'Kho lưu trữ riêng tư' }
|
|
314
|
+
]
|
|
315
|
+
});
|
|
316
|
+
if (visibility === BACK) return { ok: false, back: true };
|
|
317
|
+
|
|
318
|
+
const isPrivate = visibility === 'private';
|
|
319
|
+
|
|
320
|
+
const spinner = ora({ text: `Đang tạo repository '${repoName}' trên GitHub…`, color: 'cyan' }).start();
|
|
321
|
+
const createRes = await createGitHubRepo({
|
|
322
|
+
name: repoName,
|
|
323
|
+
description: String(description || ''),
|
|
324
|
+
isPrivate,
|
|
325
|
+
autoInit: false
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
if (!createRes.ok) {
|
|
329
|
+
spinner.fail(chalk.red('Không thể tạo repository!'));
|
|
330
|
+
printErrorBox('TẠO REPO THẤT BẠI', [createRes.error]);
|
|
331
|
+
return { ok: false, error: createRes.error };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
spinner.succeed(chalk.hex('#34D399')(`Đã tạo repo @${currentUser.user.login}/${repoName} thành công!`));
|
|
335
|
+
printRepoCreatedBox(createRes.repo);
|
|
336
|
+
|
|
337
|
+
return { ok: true, repo: createRes.repo };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Hiển thị danh sách các repo gần nhất của user
|
|
342
|
+
*/
|
|
343
|
+
export async function showUserRepos({ limit = 15 } = {}) {
|
|
344
|
+
const spinner = ora({ text: 'Đang tải danh sách repository từ GitHub…', color: 'cyan' }).start();
|
|
345
|
+
const res = await listUserRepos({ limit });
|
|
346
|
+
spinner.stop();
|
|
347
|
+
|
|
348
|
+
if (!res.ok) {
|
|
349
|
+
printErrorBox('KHÔNG TẢI ĐƯỢC DANH SÁCH REPO', [res.error]);
|
|
350
|
+
return [];
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (res.repos.length === 0) {
|
|
354
|
+
console.log(chalk.hex('#94A3B8')(' » Bạn chưa có repository nào trên tài khoản này.\n'));
|
|
355
|
+
return [];
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
printSection(`DANH SÁCH REPOSITORIES (${res.repos.length} REPO GẦN ĐÂY)`);
|
|
359
|
+
res.repos.forEach((r, idx) => {
|
|
360
|
+
const lock = r.private ? chalk.hex('#F59E0B')('[Private]') : chalk.hex('#10B981')('[Public ]');
|
|
361
|
+
const num = chalk.hex('#64748B')(String(idx + 1).padStart(2, ' '));
|
|
362
|
+
const name = chalk.hex('#F8FAFC').bold(r.name);
|
|
363
|
+
const url = chalk.hex('#38BDF8')(r.clone_url);
|
|
364
|
+
console.log(` ${num}. ${lock} ${name}`);
|
|
365
|
+
console.log(` ${chalk.hex('#64748B')('URL:')} ${url}`);
|
|
366
|
+
});
|
|
367
|
+
console.log('');
|
|
368
|
+
return res.repos;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Đăng xuất tài khoản GitHub (mặc định: tài khoản đang active)
|
|
373
|
+
*/
|
|
374
|
+
export function logoutGitHub(login = null) {
|
|
375
|
+
const accounts = getAllGitHubAccounts();
|
|
376
|
+
const target = login || accounts.find((a) => a.active)?.login || accounts[0]?.login || 'GitHub';
|
|
377
|
+
removeGitHubToken(login);
|
|
378
|
+
console.log(chalk.hex('#34D399')(`\n [OK] Đã đăng xuất @${target} và xóa token khỏi máy.\n`));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Menu tương tác quản lý GitHub
|
|
383
|
+
*/
|
|
384
|
+
export async function runGitHubMenu({ defaultDir = process.cwd() } = {}) {
|
|
385
|
+
while (true) {
|
|
386
|
+
const userStatus = await getCurrentGitHubUser();
|
|
387
|
+
const isLogged = userStatus.authenticated;
|
|
388
|
+
|
|
389
|
+
printSection('QUẢN LÝ GITHUB & REPOSITORY');
|
|
390
|
+
if (isLogged) {
|
|
391
|
+
console.log(chalk.hex('#34D399')(` [✓] Đang đăng nhập: @${userStatus.user.login} (${userStatus.user.name || 'Developer'})`));
|
|
392
|
+
} else {
|
|
393
|
+
console.log(chalk.hex('#F59E0B')(' [!] Chưa kết nối GitHub.'));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const choices = [
|
|
397
|
+
{ name: 'Tạo repository', value: 'create', description: 'Tạo repo mới và có thể đẩy code ngay' },
|
|
398
|
+
{ name: 'Tài khoản GitHub', value: 'status', description: 'Xem profile và trạng thái kết nối' },
|
|
399
|
+
{ name: 'Danh sách repository', value: 'list', description: 'Duyệt repo cá nhân và URL clone' },
|
|
400
|
+
isLogged
|
|
401
|
+
? { name: 'Đăng nhập tài khoản khác', value: 'login', description: 'Thêm hoặc chuyển tài khoản GitHub' }
|
|
402
|
+
: { name: 'Đăng nhập', value: 'login', description: 'Kết nối bằng trình duyệt hoặc token' },
|
|
403
|
+
...(isLogged ? [{ name: 'Đăng xuất', value: 'logout', description: 'Xóa kết nối tài khoản đang dùng' }] : []),
|
|
404
|
+
{ name: 'Quay lại', value: BACK, description: 'Về bảng điều khiển' }
|
|
405
|
+
];
|
|
406
|
+
|
|
407
|
+
const action = await select({
|
|
408
|
+
message: 'Chọn tác vụ GitHub:',
|
|
409
|
+
choices
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
if (action === BACK) return;
|
|
413
|
+
|
|
414
|
+
if (action === 'status') {
|
|
415
|
+
await showGitHubStatus();
|
|
416
|
+
} else if (action === 'login') {
|
|
417
|
+
await promptGitHubLogin();
|
|
418
|
+
} else if (action === 'create') {
|
|
419
|
+
const res = await promptCreateRepo({ targetDir: defaultDir });
|
|
420
|
+
if (res.ok && res.repo) {
|
|
421
|
+
const askPush = await select({
|
|
422
|
+
message: 'Bạn có muốn thiết lập remote và đẩy dự án hiện tại lên repo mới này ngay không?',
|
|
423
|
+
choices: [
|
|
424
|
+
{ name: 'Có, đẩy dự án lên repo này ngay lập tức', value: 'push', description: 'Tự động gán origin và chuyển sang màn hình push' },
|
|
425
|
+
{ name: 'Không, chỉ tạo repo thôi', value: 'skip', description: 'Hoàn tất' }
|
|
426
|
+
]
|
|
427
|
+
});
|
|
428
|
+
if (askPush === 'push') {
|
|
429
|
+
return { autoPush: true, repoUrl: res.repo.cloneUrl };
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
} else if (action === 'list') {
|
|
433
|
+
await showUserRepos();
|
|
434
|
+
} else if (action === 'logout') {
|
|
435
|
+
logoutGitHub();
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|