git-push-time 1.1.0 → 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.
@@ -1,436 +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 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: description === BACK ? '' : 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
373
- */
374
- export function logoutGitHub() {
375
- removeGitHubToken();
376
- console.log(chalk.hex('#34D399')('\n [OK] Đã đăng xuất và xóa token GitHub khỏi cấu hình máy.\n'));
377
- }
378
-
379
- /**
380
- * Menu tương tác quản lý GitHub
381
- */
382
- export async function runGitHubMenu({ defaultDir = process.cwd() } = {}) {
383
- while (true) {
384
- const userStatus = await getCurrentGitHubUser();
385
- const isLogged = userStatus.authenticated;
386
-
387
- printSection('QUẢN LÝ GITHUB & REPOSITORY');
388
- if (isLogged) {
389
- console.log(chalk.hex('#34D399')(` [✓] Đang đăng nhập: @${userStatus.user.login} (${userStatus.user.name || 'Developer'})`));
390
- } else {
391
- console.log(chalk.hex('#F59E0B')(' [!] Chưa kết nối GitHub.'));
392
- }
393
-
394
- const choices = [
395
- { name: '» TẠO REPO | Tạo repository mới trên GitHub', value: 'create', description: 'Tạo repo mới (Public/Private) và đẩy code ngay' },
396
- { name: '» PROFILE | Xem thông tin tài khoản GitHub', value: 'status', description: 'Kiểm tra tài khoản, số lượng repo, quyền hạn' },
397
- { name: '» DANH SÁCH | Xem danh sách repository cá nhân', value: 'list', description: 'Hiển thị các repo gần đây và URL clone' },
398
- isLogged
399
- ? { name: '» ĐỔI/THÊM | Đăng nhập tài khoản khác (OAuth/Token)', value: 'login', description: 'Đăng nhập tài khoản mới' }
400
- : { name: '» ĐĂNG NHẬP | Kết nối GitHub (OAuth Device Flow / Token)', value: 'login', description: 'Đăng nhập tài khoản GitHub' },
401
- ...(isLogged ? [{ name: '» ĐĂNG XUẤT | Xóa tài khoản khỏi máy', value: 'logout', description: 'Hủy kết nối tài khoản GitHub' }] : []),
402
- { name: '« QUAY LẠI | Về menu chính', value: BACK, description: 'Quay lại menu điều khiển' }
403
- ];
404
-
405
- const action = await select({
406
- message: 'Chọn tác vụ GitHub:',
407
- choices
408
- });
409
-
410
- if (action === BACK) return;
411
-
412
- if (action === 'status') {
413
- await showGitHubStatus();
414
- } else if (action === 'login') {
415
- await promptGitHubLogin();
416
- } else if (action === 'create') {
417
- const res = await promptCreateRepo({ targetDir: defaultDir });
418
- if (res.ok && res.repo) {
419
- const askPush = await select({
420
- 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?',
421
- choices: [
422
- { 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' },
423
- { name: '» Không, chỉ tạo repo thôi', value: 'skip', description: 'Hoàn tất' }
424
- ]
425
- });
426
- if (askPush === 'push') {
427
- return { autoPush: true, repoUrl: res.repo.cloneUrl };
428
- }
429
- }
430
- } else if (action === 'list') {
431
- await showUserRepos();
432
- } else if (action === 'logout') {
433
- logoutGitHub();
434
- }
435
- }
436
- }
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
+