git-push-time 1.0.3 → 1.1.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/bin/index.js CHANGED
@@ -1,1003 +1,290 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { Command } from 'commander';
4
- import chalk from 'chalk';
5
- import ora from 'ora';
6
- import fs from 'fs';
7
- import path from 'path';
8
- import { input, select } from '@inquirer/prompts';
9
- import { isValidGitUrl, parseCustomDate } from '../lib/utils.js';
4
+ import { select } from '@inquirer/prompts';
5
+ import { checkGitInstalled, getCurrentBranch } from '../lib/git.js';
6
+ import { getCurrentGitHubUser } from '../lib/github.js';
7
+ import { runPushWizard } from '../lib/commands/push.js';
8
+ import { runPullCommand } from '../lib/commands/pull.js';
9
+ import { runEditCommitTimeCommand } from '../lib/commands/edit-time.js';
10
+ import { runFixCommand } from '../lib/commands/fix.js';
10
11
  import {
11
- checkGitInstalled,
12
- isGitRepo,
13
- initGitRepo,
14
- getGitTopLevel,
15
- listSubdirectories,
16
- resolveTargetDir,
17
- setGitRemote,
18
- stageAllFiles,
19
- hasChangesToCommit,
20
- commitWithCustomDate,
21
- setBranchName,
22
- pushToRemote,
23
- getCurrentBranch,
24
- findRiskyStagedFiles,
25
- pullFromRemote,
26
- pullRebaseFromRemote,
27
- getRecentCommits,
28
- rewriteCommitDate
29
- } from '../lib/git.js';
30
- import { scanStagedContentForSecrets } from '../lib/secrets.js';
31
- import {
32
- autoFixStagedSecrets,
33
- hasStagedChanges,
34
- findRiskyWorkingTreeFiles,
35
- getUnpushedCommits,
36
- squashUnpushedCommits
37
- } from '../lib/fix.js';
12
+ runGitHubMenu,
13
+ showGitHubStatus,
14
+ promptCreateRepo,
15
+ showUserRepos,
16
+ promptGitHubLogin,
17
+ logoutGitHub
18
+ } from '../lib/commands/github.js';
19
+ import { promptContinueOrBack } from '../lib/prompt-helpers.js';
38
20
  import {
39
21
  printBanner,
40
- printSummary,
22
+ printDashboardStatus,
41
23
  printSection,
42
- stepLabel,
43
- printTargetDir,
44
- printNestedRepoWarning,
45
- printPrePushSecretWarning,
46
- printSecretBlockedBox,
47
- printSuccessBox,
48
- printCancelled,
49
- printErrorBox,
50
24
  printGitMissing,
51
- printAuthFailedBox,
52
- printNonFastForwardBox,
53
- printFixResult,
54
- printFooter,
25
+ printCancelled,
55
26
  styleCliHelp
56
27
  } from '../lib/ui.js';
57
28
 
58
- const VERSION = '1.0.3';
59
- const TOTAL_STEPS = 6;
60
-
61
- /* ==========================================================================
62
- BACK-NAVIGATION HELPERS — moi buoc chon/nhap deu co nut Quay lai.
63
- Quy uoc: go ".." trong o nhap = Quay lai (tuong duong nut « Quay lai).
64
- Khong dung emoji — an tuong bang chu in hoa + vien + mau.
65
- ========================================================================== */
66
- const BACK = '__back__';
67
- const BACK_TOKENS = new Set(['..', '/back', '<<']);
68
-
69
- function isBackInput(v) {
70
- return BACK_TOKENS.has(String(v || '').trim());
71
- }
72
-
73
- async function selectWithBack({ message, choices, pageSize, ...rest }) {
74
- const hasBack = (choices || []).some((c) => c && c.value === BACK);
75
- const finalChoices = hasBack
76
- ? choices
77
- : [
78
- ...choices,
79
- { name: '« Quay lại (bước trước)', value: BACK, description: 'Về bước trước / menu chính' }
80
- ];
81
- return await select({
82
- message,
83
- choices: finalChoices,
84
- pageSize: pageSize || Math.min(12, finalChoices.length + 2),
85
- ...rest
86
- });
87
- }
88
-
89
- async function inputWithBack({ message, validate, ...rest }) {
90
- const hinted = `${message} [gõ ".." để quay lại]`;
91
- const wrappedValidate = (val) => {
92
- if (isBackInput(val)) return true;
93
- if (validate) return validate(val);
94
- return true;
95
- };
96
- const val = await input({ message: hinted, validate: wrappedValidate, ...rest });
97
- if (isBackInput(val)) return BACK;
98
- return val;
99
- }
29
+ const VERSION = '1.1.0';
100
30
 
101
31
  /**
102
- * Cho user chọn đúng folder nguồn cần push (tránh up nhầm cả folder gốc ngoài cùng).
103
- * Luon co nut « Quay lai de ve menu chinh.
104
- * @param {string} startDir thư mục chạy lệnh
105
- * @returns {Promise<string>} đường dẫn tuyệt đối của folder được chọn, hoac BACK
32
+ * Kiểm tra xem lệnh có được gọi "trần" (không flag/subcommand nào) hay không.
106
33
  */
107
- async function promptForTargetDir(startDir) {
108
- const subs = listSubdirectories(startDir);
109
- if (subs.length === 0) return startDir;
110
-
111
- printSection('CHỌN FOLDER NGUỒN ĐỂ PUSH');
112
- printTargetDir(startDir);
113
-
114
- const picked = await selectWithBack({
115
- message: 'Folder nào sẽ được đẩy lên Git?',
116
- pageSize: Math.min(12, subs.length + 3),
117
- choices: [
118
- { name: '» Thư mục hiện tại (.)', value: startDir, description: 'Push toàn bộ thư mục hiện tại' },
119
- ...subs.map((s) => ({
120
- name: `» ${s.name}`,
121
- value: s.fullPath,
122
- description: s.fullPath
123
- })),
124
- { name: '» Nhập đường dẫn khác...', value: '__custom__', description: 'Gõ tay đường dẫn tuyệt đối/tương đối' }
125
- ]
126
- });
127
-
128
- if (picked === BACK) return BACK;
129
- if (picked !== '__custom__') return picked;
130
-
131
- const customPath = await inputWithBack({
132
- message: 'Nhập đường dẫn folder nguồn:',
133
- validate: (value) => {
134
- const r = resolveTargetDir(value, startDir);
135
- return r.ok || r.error;
136
- }
137
- });
138
- if (customPath === BACK) return BACK;
139
- return resolveTargetDir(customPath, startDir).fullPath;
34
+ function isBareInvocation(options = {}) {
35
+ return (
36
+ !options.repo &&
37
+ !options.message &&
38
+ !options.date &&
39
+ !options.branch &&
40
+ !options.dir &&
41
+ !options.force &&
42
+ !options.fix
43
+ );
140
44
  }
141
45
 
142
46
  /**
143
- * Nếu folder được chọn nằm trong Git repo của folder CHA mà chưa có .git riêng,
144
- * hỏi user muốn tách repo riêng (chỉ push folder này) hay dùng chung repo cha.
145
- * Luon co nut « Quay lai de chon lai folder.
146
- * @returns {Promise<string>} 'separate' | 'shared' | BACK
47
+ * Menu chính khi chạy `git-push-time` không truyền flag
147
48
  */
148
- async function handleNestedRepo(targetDir, isInteractive, options) {
149
- const topLevel = getGitTopLevel(targetDir);
150
- if (!topLevel) return; // chưa có repo nào -> bước init sẽ tạo mới, an toàn
151
- if (path.resolve(topLevel) === path.resolve(targetDir)) return; // đã là root repo, ok
152
- if (fs.existsSync(path.join(targetDir, '.git'))) return; // đã có repo riêng, ok
153
-
154
- // Nằm trong repo cha -> cảnh báo bằng card theo đúng style UI chung
155
- printNestedRepoWarning({ targetDir, topLevel });
156
-
157
- let choice = 'separate';
158
- if (isInteractive && !options.yes) {
159
- choice = await selectWithBack({
160
- message: 'Bạn muốn push thế nào?',
161
- choices: [
162
- { name: '» Tạo repo RIÊNG chỉ cho folder này (khuyên dùng)', value: 'separate', description: 'git init trong folder này, chỉ up nội dung của nó' },
163
- { name: '» Dùng chung repo của folder cha', value: 'shared', description: 'Giữ nguyên, push theo repo cha' },
164
- { name: 'x Hủy để kiểm tra lại', value: 'abort', description: 'Thoát, không làm gì' }
165
- ]
166
- });
167
- if (choice === BACK) return BACK;
168
- } else if (options.dir) {
169
- // Chế độ -y + có --dir: mặc định tách repo riêng cho đúng ý "chọn đúng folder"
170
- choice = 'separate';
171
- } else {
172
- // Chế độ -y ở thư mục hiện tại: giữ hành vi cũ, không tự tách
173
- return;
174
- }
175
-
176
- if (choice === 'abort') {
177
- printCancelled();
178
- process.exit(0);
179
- }
180
- if (choice === 'separate') {
181
- initGitRepo(targetDir);
182
- console.log(chalk.hex('#34D399')(' [OK] Đã tạo Git repo riêng cho folder này.\n'));
183
- } else {
184
- console.log(chalk.hex('#64748B')(' » Dùng chung repo cha (chỉ stage file trong folder này).\n'));
185
- }
186
- }
187
-
188
- /**
189
- * Menu chính khi chạy `git-push-time` mà không truyền flag/subcommand.
190
- * @returns {Promise<string>} 'push' | 'pull' | 'edit-time' | 'fix' | 'exit'
191
- */
192
- async function promptMainAction() {
193
- printSection('CHỌN HÀNH ĐỘNG');
49
+ async function promptMainMenu() {
50
+ printSection('BẢNG ĐIỀU KHIỂN CHÍNH');
194
51
  return await select({
195
- message: 'Bạn muốn làm gì?',
52
+ message: 'Bạn muốn thực hiện thao tác nào?',
53
+ pageSize: 10,
196
54
  choices: [
197
- { name: '» PUSH | Đẩy code lên remote', value: 'push', description: 'git add + commit + push lên remote' },
198
- { name: '» PULL | Kéo code về từ remote', value: 'pull', description: 'git pull --ff-only từ remote' },
199
- { name: '» EDIT | Sửa thời gian commit', value: 'edit-time', description: 'Đổi Author/Committer Date của commit' },
200
- { name: '» FIX | Quét + gỡ secret', value: 'fix', description: 'Quét + gỡ secret, làm sạch commit chưa push' },
201
- { name: 'x THOÁT | Không làm gì', value: 'exit', description: 'Thoát, không làm gì cả' }
55
+ {
56
+ name: '» [PUSH] | Đẩy code lên Git (kèm chọn folder, backdate & chống lộ secret)',
57
+ value: 'push',
58
+ description: 'Đẩy dự án lên remote repository hiện tại hoặc mới'
59
+ },
60
+ {
61
+ name: '» [GITHUB] | Quản lý GitHub & Tạo repository mới',
62
+ value: 'github',
63
+ description: 'Tạo repo mới trên GitHub, xem profile, chọn repo nhanh'
64
+ },
65
+ {
66
+ name: '» [PULL] | Cập nhật code mới về máy',
67
+ value: 'pull',
68
+ description: 'git pull --ff-only hoặc --rebase từ remote'
69
+ },
70
+ {
71
+ name: '» [TIME] | Sửa thời gian commit lịch sử',
72
+ value: 'edit-time',
73
+ description: 'Đổi Author/Committer Date của commit trong quá khứ'
74
+ },
75
+ {
76
+ name: '» [FIX] | Quét & gỡ sạch secret khỏi repo',
77
+ value: 'fix',
78
+ description: 'Quét file nhạy cảm, gỡ khỏi stage và squash commit'
79
+ },
80
+ {
81
+ name: 'x [THOÁT] | Kết thúc chương trình',
82
+ value: 'exit',
83
+ description: 'Thoát ứng dụng'
84
+ }
202
85
  ]
203
86
  });
204
87
  }
205
88
 
206
89
  /**
207
- * Hỏi Tiếp tục / Quay lại menu / Thoát sau mỗi tác vụ hoặc khi gặp lỗi.
208
- * @param {string} message
209
- * @returns {Promise<string>} 'continue' | 'back' | 'exit'
90
+ * Vòng lặp Menu chính
210
91
  */
211
- async function promptContinueOrBack(message = 'Bạn muốn tiếp tục hay quay lại?') {
212
- return await select({
213
- message,
214
- choices: [
215
- { name: '» Tiếp tục', value: 'continue', description: 'Đi tiếp bước kế tiếp' },
216
- { name: '« Quay lại menu chính', value: 'back', description: 'Về màn hình chọn hành động' },
217
- { name: 'x Thoát', value: 'exit', description: 'Dừng, không làm gì' }
218
- ]
219
- });
220
- }
92
+ async function runMainMenuLoop(initialOptions = {}) {
93
+ while (true) {
94
+ const cwd = process.cwd();
95
+ const branch = getCurrentBranch(cwd);
96
+ const ghStatus = await getCurrentGitHubUser();
97
+
98
+ printDashboardStatus({
99
+ targetDir: cwd,
100
+ currentBranch: branch,
101
+ githubUser: ghStatus.authenticated ? ghStatus.user : null
102
+ });
221
103
 
222
- /**
223
- * Kiểm tra lệnh có được gọi "trần" (không flag push nào) hay không.
224
- * Chỉ khi gọi trần + interactive mới hiện menu chính để không phá hành vi cũ (-y / -r / -m...).
225
- */
226
- function isBarePushInvocation(options = {}) {
227
- return !options.repo && !options.message && !options.date && !options.branch && !options.dir && !options.force && !options.fix;
228
- }
104
+ const action = await promptMainMenu();
229
105
 
230
- /**
231
- * Vòng lặp menu chính: Push / Pull / Edit-time / Fix.
232
- * Sau mỗi tác vụ xong sẽ hỏi Tiếp tục (ở lại menu) hay Thoát.
233
- */
234
- async function runMainMenuLoop() {
235
- while (true) {
236
- const action = await promptMainAction();
237
106
  if (action === 'exit') {
238
107
  printCancelled();
239
108
  process.exit(0);
240
109
  }
110
+
241
111
  if (action === 'push') {
242
- return; // thoát loop để chảy tiếp xuống flow push bên dưới
112
+ await runPushWizard(initialOptions, async () => {
113
+ // Callback quay lại menu chính
114
+ return await runMainMenuLoop(initialOptions);
115
+ });
116
+ return;
243
117
  }
244
- if (action === 'pull') {
118
+
119
+ if (action === 'github') {
120
+ const ghResult = await runGitHubMenu({ defaultDir: cwd });
121
+ if (ghResult && ghResult.autoPush && ghResult.repoUrl) {
122
+ // Sau khi tạo repo mới, chuyển tiếp thẳng vào luồng push
123
+ await runPushWizard({ ...initialOptions, repo: ghResult.repoUrl });
124
+ return;
125
+ }
126
+ } else if (action === 'pull') {
245
127
  await runPullCommand('origin', undefined, { fromMenu: true });
246
128
  } else if (action === 'edit-time') {
247
129
  await runEditCommitTimeCommand({ fromMenu: true });
248
130
  } else if (action === 'fix') {
249
131
  await runFixCommand({ fromMenu: true });
250
132
  }
251
- // Sau khi xong 1 tác vụ (pull/edit/fix), hỏi ở lại menu hay thoát.
252
- const next = await promptContinueOrBack('Hoàn tất! Bạn muốn tiếp tục hay quay lại?');
133
+
134
+ const next = await promptContinueOrBack('Hoàn tất! Bạn muốn tiếp tục hay quay lại bảng điều khiển?');
253
135
  if (next === 'exit') {
254
136
  process.exit(0);
255
137
  }
256
- if (next === 'continue' || next === 'back') {
257
- continue; // quay lại menu chính
258
- }
259
138
  }
260
139
  }
261
140
 
262
- async function run(options = {}) {
141
+ async function main(options = {}) {
263
142
  printBanner(VERSION);
264
143
 
265
- // 1. Kiểm tra Git
266
144
  if (!checkGitInstalled()) {
267
- printBanner(VERSION);
268
145
  printGitMissing();
269
146
  process.exit(1);
270
147
  }
271
148
 
272
149
  const isInteractive = !options.yes;
273
150
 
274
- // 1a. Nếu chạy trần `git-push-time` (không flag, không subcommand) thì hiện menu chính:
275
- // Push / Pull / Sửa thời gian / Fix secret — bổ sung lựa chọn Pull còn thiếu.
276
- if (isInteractive && isBarePushInvocation(options)) {
277
- await runMainMenuLoop();
278
- // Người dùng đã chọn "Đẩy code lên (Push)" -> chảy tiếp xuống flow push bên dưới.
279
- }
280
-
281
- const startDir = process.cwd();
282
-
283
- // Wizard Push theo tung buoc — moi buoc deu co nut « Quay lai.
284
- // Buoc 0: folder | 1: repoUrl | 2: commitMsg | 3: date | 4: branch | 5: xac nhan
285
- let targetDir = startDir;
286
- let repoUrl = options.repo;
287
- let commitMsg = options.message;
288
- let dateInput = options.date;
289
- let branchName = options.branch;
290
- let parsedDate = null;
291
-
292
- if (options.dir) {
293
- const r = resolveTargetDir(options.dir, startDir);
294
- if (!r.ok) {
295
- printErrorBox('THƯ MỤC NGUỒN KHÔNG HỢP LỆ', [r.error], 'VD: --dir ./my-app hoặc --dir "E:/projects/my-app"');
296
- process.exit(1);
297
- }
298
- targetDir = r.fullPath;
299
- }
300
-
301
- if (!isInteractive) {
302
- if (!commitMsg) commitMsg = 'Initial commit';
303
- if (!dateInput) dateInput = 'now';
304
- if (!branchName) branchName = 'main';
305
- if (!repoUrl || !isValidGitUrl(repoUrl)) {
306
- printErrorBox('URL REPOSITORY KHÔNG HỢP LỆ', [`URL nhận được: '${repoUrl || '(trống)'}'`], 'Ví dụ đúng: https://github.com/username/repository.git');
307
- process.exit(1);
308
- }
309
- parsedDate = parseCustomDate(dateInput);
310
- if (!parsedDate.valid) {
311
- printErrorBox('THỜI GIAN KHÔNG HỢP LỆ', [parsedDate.error], 'VD: now, -2d, -5h, 2024-01-15 14:30:00');
312
- process.exit(1);
313
- }
314
- printSummary({ repoUrl, commitMsg, dateDisplay: parsedDate.display, branch: branchName, force: options.force, sourceDir: targetDir });
315
- } else {
316
- let step = options.dir ? 1 : 0;
317
- const cameFromMenu = isBarePushInvocation(options);
318
- while (true) {
319
- // Buoc 0: chon folder
320
- if (step === 0) {
321
- const picked = await promptForTargetDir(startDir);
322
- if (picked === BACK) {
323
- if (cameFromMenu) {
324
- await runMainMenuLoop();
325
- // Menu tra ve nghia la user lai chon Push -> o lai buoc 0 de chon lai folder
326
- if (!options.repo) repoUrl = undefined;
327
- if (!options.message) commitMsg = undefined;
328
- if (!options.date) dateInput = undefined;
329
- if (!options.branch) branchName = undefined;
330
- continue;
331
- }
332
- printCancelled();
333
- process.exit(0);
334
- }
335
- targetDir = picked;
336
- const nested = await handleNestedRepo(targetDir, isInteractive, options);
337
- if (nested === BACK) continue; // quay lai chon folder
338
- step = 1;
339
- continue;
340
- }
341
- // Bước 1: repo URL
342
- if (step === 1) {
343
- if (options.repo) { repoUrl = options.repo; step = 2; continue; }
344
- if (!repoUrl) {
345
- printSection('KẾT NỐI REPOSITORY');
346
- const val = await inputWithBack({
347
- message: 'Dán link Git Repository:',
348
- validate: (value) => {
349
- if (!value || !value.trim()) return 'Vui lòng nhập đường dẫn repository!';
350
- if (!isValidGitUrl(value)) return 'URL chưa đúng (vd: https://github.com/user/repo.git)';
351
- return true;
352
- }
353
- });
354
- if (val === BACK) {
355
- // Về bước chọn folder (hoặc về menu nếu có --dir + chạy từ menu)
356
- if (options.dir) {
357
- if (cameFromMenu) { await runMainMenuLoop(); continue; }
358
- step = 1;
359
- } else { step = 0; }
360
- continue;
361
- }
362
- repoUrl = val;
363
- }
364
- if (!repoUrl || !isValidGitUrl(repoUrl)) {
365
- printErrorBox('URL REPOSITORY KHÔNG HỢP LỆ', [`URL nhận được: '${repoUrl || '(trống)'}'`], 'Ví dụ đúng: https://github.com/username/repository.git');
366
- const retry = await promptContinueOrBack('URL chưa đúng. Bạn muốn nhập lại hay quay lại?');
367
- if (retry === 'exit') { printCancelled(); process.exit(0); }
368
- repoUrl = options.repo;
369
- if (retry === 'back') {
370
- if (cameFromMenu) await runMainMenuLoop();
371
- step = options.dir ? 1 : 0;
372
- }
373
- continue;
374
- }
375
- step = 2;
376
- continue;
377
- }
378
- // Bước 2: commit message
379
- if (step === 2) {
380
- if (options.message) { commitMsg = options.message; step = 3; continue; }
381
- if (!commitMsg) {
382
- const val = await inputWithBack({ message: 'Thông điệp commit:', default: 'Initial commit' });
383
- if (val === BACK) { step = 1; if (!options.repo) repoUrl = undefined; continue; }
384
- commitMsg = val || 'Initial commit';
385
- }
386
- step = 3;
387
- continue;
388
- }
389
- // Bước 3: thời gian
390
- if (step === 3) {
391
- if (options.date) { dateInput = options.date; step = 4; continue; }
392
- if (!dateInput) {
393
- printSection('THỜI GIAN COMMIT');
394
- const dateOption = await selectWithBack({
395
- message: 'Chọn thời gian cho commit:',
396
- choices: [
397
- { name: '» Ngay bây giờ', value: 'now', description: 'Dùng thời gian hiện tại' },
398
- { name: '» Lùi thời gian', value: 'relative', description: 'VD: -2d, -5h, -30m, -1w' },
399
- { name: '» Ngày giờ cụ thể', value: 'custom', description: 'VD: 2024-01-15 14:30:00' }
400
- ]
401
- });
402
- if (dateOption === BACK) { step = 2; if (!options.message) commitMsg = undefined; continue; }
403
- if (dateOption === 'now') {
404
- dateInput = 'now';
405
- } else if (dateOption === 'relative') {
406
- const val = await inputWithBack({
407
- message: 'Nhập thời gian lùi (VD: -2d, -5h, -30m, -1w):',
408
- default: '-1d',
409
- validate: (v) => { const p = parseCustomDate(v); return p.valid || p.error || 'Thời gian không hợp lệ'; }
410
- });
411
- if (val === BACK) continue; // quay lại chọn kiểu thời gian
412
- dateInput = val;
413
- } else if (dateOption === 'custom') {
414
- const val = await inputWithBack({
415
- message: 'Nhập ngày giờ (YYYY-MM-DD HH:mm:ss):',
416
- default: '2024-01-15 10:00:00',
417
- validate: (v) => { const p = parseCustomDate(v); return p.valid || p.error || 'Ngày giờ không hợp lệ'; }
418
- });
419
- if (val === BACK) continue;
420
- dateInput = val;
421
- }
422
- }
423
- parsedDate = parseCustomDate(dateInput || 'now');
424
- if (!parsedDate.valid) {
425
- printErrorBox('THỜI GIAN KHÔNG HỢP LỆ', [parsedDate.error], 'VD: now, -2d, -5h, 2024-01-15 14:30:00');
426
- const retry = await promptContinueOrBack('Thời gian chưa đúng. Bạn muốn nhập lại hay quay lại?');
427
- if (retry === 'exit') { printCancelled(); process.exit(0); }
428
- dateInput = options.date;
429
- if (retry === 'back') { step = 2; if (!options.message) commitMsg = undefined; }
430
- continue;
431
- }
432
- if (!dateInput) dateInput = 'now';
433
- step = 4;
434
- continue;
435
- }
436
- // Bước 4: branch
437
- if (step === 4) {
438
- if (options.branch) { branchName = options.branch; step = 5; continue; }
439
- if (!branchName) {
440
- const currentB = getCurrentBranch(targetDir);
441
- const val = await inputWithBack({ message: 'Branch trên remote:', default: currentB || 'main' });
442
- if (val === BACK) { step = 3; if (!options.date) dateInput = undefined; continue; }
443
- branchName = val || currentB || 'main';
444
- }
445
- if (!branchName) branchName = 'main';
446
- step = 5;
447
- continue;
448
- }
449
- // Bước 5: xác nhận — có nút quay về từng bước trước
450
- if (step === 5) {
451
- if (!commitMsg) commitMsg = 'Initial commit';
452
- if (!dateInput) dateInput = 'now';
453
- if (!branchName) branchName = 'main';
454
- parsedDate = parseCustomDate(dateInput);
455
- printSummary({ repoUrl, commitMsg, dateDisplay: parsedDate.display, branch: branchName, force: options.force, sourceDir: targetDir });
456
- const decision = await select({
457
- message: 'Xác nhận đẩy lên Git ngay?',
458
- choices: [
459
- { name: '» Tiếp tục đẩy', value: 'continue', description: 'Bắt đầu git init/add/commit/push' },
460
- { name: '« Quay lại sửa branch', value: 'back-branch', description: 'Về bước nhập branch' },
461
- { name: '« Quay lại sửa thời gian', value: 'back-date', description: 'Về bước chọn thời gian' },
462
- { name: '« Quay lại sửa message', value: 'back-msg', description: 'Về bước nhập message' },
463
- { name: '« Quay lại sửa repo URL', value: 'back-url', description: 'Về bước nhập URL' },
464
- { name: '« Quay lại sửa folder', value: 'back-folder', description: 'Về bước chọn folder' },
465
- { name: '« Quay lại menu chính', value: 'back-menu', description: 'Đổi sang Pull / Sửa thời gian / Fix' },
466
- { name: 'x Hủy', value: 'exit', description: 'Thoát, không làm gì' }
467
- ]
468
- });
469
- if (decision === 'exit') { printCancelled(); process.exit(0); }
470
- if (decision === 'continue') break;
471
- if (decision === 'back-branch') { if (!options.branch) branchName = undefined; step = 4; continue; }
472
- if (decision === 'back-date') { if (!options.date) dateInput = undefined; step = 3; continue; }
473
- if (decision === 'back-msg') { if (!options.message) commitMsg = undefined; step = 2; continue; }
474
- if (decision === 'back-url') { if (!options.repo) repoUrl = undefined; step = 1; continue; }
475
- if (decision === 'back-folder') { step = options.dir ? 1 : 0; if (options.dir && cameFromMenu) { await runMainMenuLoop(); } continue; }
476
- if (decision === 'back-menu') {
477
- await runMainMenuLoop();
478
- if (!options.repo) repoUrl = undefined;
479
- if (!options.message) commitMsg = undefined;
480
- if (!options.date) dateInput = undefined;
481
- if (!options.branch) branchName = undefined;
482
- step = options.dir ? 1 : 0;
483
- continue;
484
- }
485
- }
486
- }
151
+ if (isInteractive && isBareInvocation(options)) {
152
+ await runMainMenuLoop(options);
153
+ return;
487
154
  }
488
155
 
489
- // 3. Thực thi
490
- const spinner = ora({ color: 'cyan' });
491
- printSection(`ĐANG TRIỂN KHAI (${TOTAL_STEPS} bước)`);
492
-
493
- try {
494
- // [1/6] Git init
495
- if (!isGitRepo(targetDir)) {
496
- spinner.start(stepLabel(1, TOTAL_STEPS, 'Khởi tạo Git repo (git init)...'));
497
- initGitRepo(targetDir);
498
- spinner.succeed(stepLabel(1, TOTAL_STEPS, chalk.hex('#34D399')('Đã khởi tạo Git repo')));
499
- } else {
500
- console.log(' ' + stepLabel(1, TOTAL_STEPS, chalk.hex('#64748B')('Đã có Git repo sẵn — bỏ qua init')));
501
- }
502
-
503
- // [2/6] Remote
504
- spinner.start(stepLabel(2, TOTAL_STEPS, 'Cấu hình remote origin...'));
505
- setGitRemote(repoUrl, targetDir);
506
- spinner.succeed(stepLabel(2, TOTAL_STEPS, chalk.hex('#34D399')('Cấu hình remote origin hoàn tất')));
507
-
508
- // [3/6] Stage
509
- spinner.start(stepLabel(3, TOTAL_STEPS, 'Stage files (git add .)...'));
510
- stageAllFiles(targetDir);
511
- spinner.succeed(stepLabel(3, TOTAL_STEPS, chalk.hex('#34D399')('Đã stage toàn bộ files')));
512
-
513
- // [3.5] Quét secret: tên file + nội dung
514
- const risky = findRiskyStagedFiles(targetDir);
515
- const findings = scanStagedContentForSecrets(targetDir);
516
- if (risky.length > 0 || findings.length > 0) {
517
- spinner.stop();
518
- printPrePushSecretWarning({ riskyFiles: risky, findings });
519
- const allFiles = [...new Set([...risky, ...findings.map((f) => f.file)])];
520
-
521
- let wantFix = options.fix === true;
522
- if (!wantFix && isInteractive) {
523
- const fixChoice = await selectWithBack({
524
- message: 'Tự động fix giúp bạn? (git rm --cached + thêm .gitignore, giữ file ở máy)',
525
- choices: [
526
- { name: '» Có, tự động fix', value: 'yes', description: 'Gỡ file secret khỏi stage' },
527
- { name: '» Không, giữ nguyên', value: 'no', description: 'Tiếp tục mà không fix' }
528
- ]
529
- });
530
- if (fixChoice === BACK) {
531
- console.log(chalk.hex('#64748B')(' » Bạn chọn quay lại. Hủy push để chạy lại từ bước xác nhận.\n'));
532
- printCancelled();
533
- process.exit(0);
534
- }
535
- wantFix = fixChoice === 'yes';
536
- }
537
-
538
- if (wantFix) {
539
- spinner.start('Đang tự fix: gỡ file secret khỏi stage...');
540
- const res = autoFixStagedSecrets(allFiles, targetDir);
541
- spinner.succeed(chalk.hex('#34D399')(`[OK] Đã gỡ ${res.fixed.length} file khỏi commit`));
542
- printFixResult(res);
543
-
544
- // Đảm bảo .gitignore được stage trước khi kiểm tra hasStagedChanges
545
- try {
546
- const { execSync } = await import('child_process');
547
- execSync('git add .gitignore', { cwd: targetDir, stdio: 'ignore' });
548
- } catch { /* bỏ qua */ }
549
-
550
- if (!hasStagedChanges(targetDir)) {
551
- printErrorBox('KHÔNG CÒN FILE ĐỂ COMMIT', [
552
- 'Sau khi gỡ secret thì không còn file nào để commit.',
553
- 'File secret vẫn nằm ở máy bạn, chỉ là không push lên nữa.',
554
- ]);
555
- process.exit(0);
556
- }
557
- console.log(chalk.hex('#34D399')(' » Tiếp tục commit/push phần sạch còn lại.\n'));
558
- } else if (isInteractive) {
559
- const keepChoice = await selectWithBack({
560
- message: findings.length > 0
561
- ? `Phát hiện ${findings.length} vị trí giống secret. Vẫn tiếp tục mà không fix?`
562
- : 'Vẫn tiếp tục commit/push với các file trên mà không fix?',
563
- choices: [
564
- { name: '» Vẫn tiếp tục (chịu rủi ro)', value: 'yes', description: 'Tiếp tục mà không fix' },
565
- { name: '» Dừng lại để fix tay', value: 'no', description: 'Dừng để xử lý' }
566
- ]
567
- });
568
- const keepGoing = keepChoice === 'yes';
569
- if (keepChoice === BACK || !keepGoing) {
570
- console.log(chalk.cyan('\n » Chạy lại với --fix để tool tự gỡ giúp bạn, hoặc làm tay:'));
571
- allFiles.forEach((f) => console.log(chalk.white(` git rm --cached "${f}"`)));
572
- console.log(chalk.gray(' Sau đó chạy lại tool.\n'));
573
- process.exit(0);
574
- }
575
- console.log(chalk.yellow(' » Bạn chọn tiếp tục với file nhạy cảm. Nếu push bị chặn, tool sẽ hướng dẫn tiếp.\n'));
576
- } else {
577
- console.log(chalk.yellow(' [!] Chế độ -y: vẫn tiếp tục dù phát hiện file nhạy cảm. Dùng --fix để tự gỡ.\n'));
578
- }
579
- }
156
+ // Chạy trực tiếp push wizard với các options được cung cấp
157
+ await runPushWizard(options);
158
+ }
580
159
 
581
- // [4/6] Commit
582
- spinner.start(stepLabel(4, TOTAL_STEPS, `Tạo commit [${parsedDate.display}]...`));
583
- if (!hasChangesToCommit(targetDir)) {
584
- spinner.info(stepLabel(4, TOTAL_STEPS, chalk.hex('#94A3B8')('Không có thay đổi mới — dùng commit hiện có')));
585
- } else {
586
- commitWithCustomDate(commitMsg, parsedDate.formatted, targetDir);
587
- spinner.succeed(stepLabel(4, TOTAL_STEPS, chalk.hex('#34D399')(`Commit hoàn tất • ${chalk.hex('#C084FC').bold(parsedDate.display)}`)));
588
- }
160
+ /* ==========================================================================
161
+ COMMANDER CLI CONFIGURATION
162
+ ========================================================================== */
589
163
 
590
- // [5/6] Branch
591
- spinner.start(stepLabel(5, TOTAL_STEPS, `Đổi branch -> '${branchName}'...`));
592
- setBranchName(branchName, targetDir);
593
- spinner.succeed(stepLabel(5, TOTAL_STEPS, chalk.hex('#34D399')(`Branch: ${chalk.hex('#5EEAD4').bold(branchName)}`)));
594
-
595
- // [6/6] Push
596
- spinner.start(stepLabel(6, TOTAL_STEPS, `Push lên origin/${branchName}...`));
597
- pushToRemote(branchName, options.force, targetDir);
598
- spinner.succeed(stepLabel(6, TOTAL_STEPS, chalk.bold.hex('#34D399')('Push lên remote thành công!')));
599
-
600
- printSuccessBox({ branch: branchName, repoUrl });
601
-
602
- printFooter();
603
- } catch (error) {
604
- spinner.stop();
605
- if (error.code === 'SECRET_BLOCKED') {
606
- printSecretBlockedBox(error.message, error.details || {});
607
-
608
- // Kiểm tra xem có commit unpushed chứa vết secret cũ không để hỗ trợ squash tự động
609
- const unpushed = getUnpushedCommits(branchName, targetDir);
610
- if (unpushed.length > 0) {
611
- console.log(chalk.cyanBright(`\n » Phát hiện bạn có ${unpushed.length} commit chưa push ở local có thể chứa vết secret cũ.`));
612
- let doSquash = options.fix === true;
613
- if (!doSquash && isInteractive) {
614
- const sqChoice = await selectWithBack({
615
- message: 'Tự động làm sạch lịch sử (squash các commit unpushed thành 1 commit sạch) để push lại?',
616
- choices: [
617
- { name: '» Có, squash và push lại', value: 'yes', description: 'Làm sạch lịch sử' },
618
- { name: '» Không, để tự xử lý', value: 'no', description: 'Bỏ qua' }
619
- ]
620
- });
621
- doSquash = sqChoice === 'yes';
622
- }
623
- if (doSquash) {
624
- spinner.start('Đang squash và làm sạch commit chưa push...');
625
- const sqRes = squashUnpushedCommits(branchName, commitMsg, parsedDate.formatted, targetDir);
626
- if (sqRes.ok) {
627
- spinner.succeed(chalk.green('[OK] Đã làm sạch commit! Đang thử push lại...'));
628
- try {
629
- spinner.start(stepLabel(6, TOTAL_STEPS, `Push lại lên origin/${branchName}...`));
630
- pushToRemote(branchName, options.force, targetDir);
631
- spinner.succeed(chalk.bold.green('[OK] Push thành công'));
632
- printSuccessBox({ branch: branchName, repoUrl });
633
- printFooter();
634
- return;
635
- } catch (retryErr) {
636
- spinner.fail(chalk.red(`Push lại thất bại: ${retryErr.message}`));
637
- }
638
- } else {
639
- spinner.fail(chalk.red(`Không thể tự động squash: ${sqRes.error}`));
640
- }
641
- }
642
- }
643
- } else if (error.code === 'AUTH_FAILED') {
644
- printAuthFailedBox(error.message);
645
- } else if (error.code === 'NON_FAST_FORWARD') {
646
- printNonFastForwardBox(error.message, branchName);
647
- if (isInteractive) {
648
- const ffChoice = await select({
649
- message: 'Remote đã có commit mới hơn. Bạn muốn xử lý thế nào?',
650
- choices: [
651
- { name: '» Pull về (rebase) rồi push lại — Tiếp tục', value: 'pull-push', description: `git pull --rebase origin ${branchName} rồi push lại` },
652
- { name: '» Chỉ Pull về (chưa push vội)', value: 'pull-only', description: 'Chỉ đồng bộ code về máy' },
653
- { name: '« Quay lại menu chính', value: 'back', description: 'Về màn hình chọn Push / Pull / Fix' },
654
- { name: 'x Thoát (tự xử lý tay)', value: 'exit', description: 'Không làm gì thêm' }
655
- ]
656
- });
657
- if (ffChoice === 'pull-push' || ffChoice === 'pull-only') {
658
- try {
659
- spinner.start(`Đang pull --rebase origin/${branchName}...`);
660
- const pullOut = pullRebaseFromRemote('origin', branchName, targetDir);
661
- spinner.succeed(chalk.hex('#34D399')(`Đã pull --rebase origin/${branchName} xong`));
662
- if (pullOut) console.log(chalk.hex('#64748B')(` ${String(pullOut).split('\n')[0].slice(0, 120)}`));
663
- if (ffChoice === 'pull-push') {
664
- spinner.start(stepLabel(6, TOTAL_STEPS, `Push lại lên origin/${branchName}...`));
665
- pushToRemote(branchName, options.force, targetDir);
666
- spinner.succeed(chalk.bold.hex('#34D399')('Push lại thành công sau khi pull!'));
667
- printSuccessBox({ branch: branchName, repoUrl });
668
- printFooter();
669
- return;
670
- }
671
- console.log(chalk.hex('#34D399')('\n [OK] Đã pull code mới nhất về. Chạy lại tool để push khi bạn sẵn sàng.\n'));
672
- const afterPull = await promptContinueOrBack('Bạn muốn quay lại menu chính hay thoát?');
673
- if (afterPull !== 'exit') {
674
- await runMainMenuLoop();
675
- }
676
- return;
677
- } catch (pullErr) {
678
- spinner.fail(chalk.red(`Pull --rebase thất bại: ${String(pullErr.message).split('\n')[0]}`));
679
- printErrorBox('PULL --REBASE THẤT BẠI', [String(pullErr.message).split('\n')[0]], 'Có thể bị conflict. Giải quyết conflict (git status) rồi chạy lại tool.');
680
- const retry = await promptContinueOrBack('Pull lỗi. Bạn muốn quay lại menu hay thoát?');
681
- if (retry !== 'exit') {
682
- await runMainMenuLoop();
683
- }
684
- return;
685
- }
686
- } else if (ffChoice === 'back') {
687
- await runMainMenuLoop();
688
- return;
689
- }
690
- }
691
- } else if (error.code === 'REMOTE_OR_NETWORK') {
692
- printErrorBox('LỖI KẾT NỐI REMOTE', [String(error.message).split('\n')[0]], 'Kiểm tra URL repo, mạng, và quyền truy cập.');
693
- } else {
694
- printErrorBox('ĐẨY LÊN GIT THẤT BẠI', [error.message]);
695
- }
696
- process.exit(1);
697
- }
698
- }
164
+ const program = new Command();
165
+ styleCliHelp(program);
699
166
 
700
- /**
701
- * Lệnh fix độc lập: `git-push-time fix`
702
- * Tự động quét và fix secret ở staged, working directory và unpushed commits.
703
- */
704
- async function runFixCommand(options = {}) {
705
- const fromMenu = options.fromMenu === true;
706
- if (!fromMenu) printBanner(VERSION);
707
- printSection('TỰ ĐỘNG FIX SECRET + BẢO VỆ REPO');
167
+ program
168
+ .name('git-push-time')
169
+ .description('Bộ công cụ Git & GitHub CLI: Đẩy code siêu tốc, kết nối GitHub tạo repo, chỉnh thời gian commit và bảo vệ secret')
170
+ .version(VERSION)
171
+ .option('-r, --repo <url>', 'Đường dẫn Git repository (URL hoặc để trống để chọn/tạo mới)')
172
+ .option('-d, --date <datetime>', 'Thời gian commit (VD: "2024-01-15 14:30:00", "-2d", "-5h", "now")')
173
+ .option('-m, --message <msg>', 'Thông điệp commit (Commit message)')
174
+ .option('-b, --branch <branch>', 'Tên branch trên remote (Mặc định: main)')
175
+ .option('-C, --dir <path>', 'Thư mục nguồn cần push (Mặc định: thư mục hiện tại)')
176
+ .option('-f, --force', 'Thực hiện push đè (--force)', false)
177
+ .option('-y, --yes', 'Bỏ qua giao diện hỏi, tự động dùng tham số hoặc giá trị mặc định', false)
178
+ .option('--fix', 'Tự động gỡ file secret khỏi stage & thêm .gitignore (an toàn, không xóa file thật)', false)
179
+ .action(async (opts) => {
180
+ await main(opts);
181
+ });
708
182
 
709
- if (!checkGitInstalled()) {
710
- printGitMissing();
711
- if (fromMenu) return;
712
- process.exit(1);
713
- }
183
+ // LỆNH GITHUB
184
+ const ghCmd = program
185
+ .command('github')
186
+ .description('Quản lý tài khoản GitHub và tạo repository mới trực tiếp')
187
+ .action(async () => {
188
+ printBanner(VERSION);
189
+ await runGitHubMenu();
190
+ });
714
191
 
715
- let targetDir = process.cwd();
716
- if (options.dir) {
717
- const r = resolveTargetDir(options.dir, process.cwd());
718
- if (!r.ok) {
719
- printErrorBox('THƯ MỤC KHÔNG HỢP LỆ', [r.error]);
720
- if (fromMenu) return;
721
- process.exit(1);
192
+ ghCmd
193
+ .command('create')
194
+ .description('Tạo repository mới trên GitHub (Public hoặc Private)')
195
+ .option('-C, --dir <path>', 'Thư mục nguồn cần liên kết')
196
+ .action(async (opts) => {
197
+ printBanner(VERSION);
198
+ const res = await promptCreateRepo({ targetDir: opts.dir || process.cwd() });
199
+ if (res.ok && res.repo) {
200
+ console.log(`\n » Repo URL: ${res.repo.cloneUrl}\n`);
722
201
  }
723
- targetDir = r.fullPath;
724
- }
725
- printTargetDir(targetDir);
726
-
727
- if (!isGitRepo(targetDir)) {
728
- printErrorBox('CHƯA PHẢI GIT REPOSITORY', [`Thư mục '${targetDir}' chưa phải là Git repository.`], 'Chạy git init hoặc cd vào đúng thư mục dự án / dùng --dir.');
729
- if (fromMenu) return;
730
- process.exit(1);
731
- }
202
+ });
732
203
 
733
- const spinner = ora({ color: 'cyan' });
734
- spinner.start('Đang kiểm tra staged files và thư mục làm việc…');
735
-
736
- const risky = findRiskyStagedFiles(targetDir);
737
- const findings = scanStagedContentForSecrets(targetDir);
738
- const riskyWorking = findRiskyWorkingTreeFiles(targetDir);
739
- spinner.stop();
740
-
741
- const allDetected = [...new Set([...risky, ...findings.map((f) => f.file), ...riskyWorking])];
742
-
743
- if (allDetected.length === 0) {
744
- printSection('KẾT QUẢ QUÉT');
745
- 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'));
746
- } else {
747
- printPrePushSecretWarning({ riskyFiles: allDetected, findings });
748
- spinner.start('Đang tự động fix (gỡ khỏi stage + thêm .gitignore)...');
749
- const res = autoFixStagedSecrets(allDetected, targetDir);
750
- spinner.succeed(chalk.hex('#34D399')(`[OK] Đã xử lý xong ${res.fixed.length} file!`));
751
- printFixResult(res);
752
- }
204
+ ghCmd
205
+ .command('whoami')
206
+ .description('Xem thông tin tài khoản GitHub đang kết nối')
207
+ .action(async () => {
208
+ printBanner(VERSION);
209
+ await showGitHubStatus();
210
+ });
753
211
 
754
- // Kiểm tra xem có commit chưa push nào bị dính vết secret cũ không
755
- const currentBranch = getCurrentBranch(targetDir);
756
- const unpushed = getUnpushedCommits(currentBranch, targetDir);
757
- if (unpushed.length > 0) {
758
- printSection(`${unpushed.length} COMMIT CHƯA PUSH`);
759
- unpushed.slice(0, 5).forEach((c) => console.log(chalk.hex('#94A3B8')(` - ${c}`)));
760
- if (unpushed.length > 5) {
761
- console.log(chalk.hex('#64748B')(` ...và ${unpushed.length - 5} commit khác`));
762
- }
763
- const sqChoice = await selectWithBack({
764
- message: `Bạn có muốn gộp (squash) ${unpushed.length} commit chưa push thành 1 commit sạch để xóa dấu vết secret cũ không? [chọn « Quay lại để bỏ qua]`,
765
- choices: [
766
- { name: '» Có, squash thành 1 commit sạch', value: 'yes', description: 'Làm sạch lịch sử' },
767
- { name: '» Không, giữ nguyên', value: 'no', description: 'Bỏ qua' }
768
- ]
769
- });
770
- const okSquash = sqChoice === 'yes';
771
- if (sqChoice !== BACK && okSquash) {
772
- spinner.start('Đang làm sạch lịch sử commit chưa push...');
773
- const sq = squashUnpushedCommits(currentBranch, 'Clean commit without secrets', null, targetDir);
774
- if (sq.ok) {
775
- spinner.succeed(chalk.hex('#34D399')('[OK] Đã làm sạch commit chưa push! Sẵn sàng push lên remote.'));
776
- } else {
777
- spinner.fail(chalk.red(`Không thể squash: ${sq.error}`));
778
- }
779
- }
780
- }
212
+ ghCmd
213
+ .command('repos')
214
+ .description('Liệt kê danh sách repository trên GitHub của bạn')
215
+ .action(async () => {
216
+ printBanner(VERSION);
217
+ await showUserRepos();
218
+ });
781
219
 
782
- printSuccessBox({ branch: currentBranch || 'main', repoUrl: 'local repo đã sạch — sẵn sàng push' });
783
- }
220
+ ghCmd
221
+ .command('login')
222
+ .description('Đăng nhập tài khoản GitHub bằng OAuth Device Flow hoặc Token')
223
+ .action(async () => {
224
+ printBanner(VERSION);
225
+ await promptGitHubLogin();
226
+ });
784
227
 
785
- async function runPullCommand(remote = 'origin', branch, options = {}) {
786
- const fromMenu = options.fromMenu === true;
787
- if (!fromMenu) printBanner(VERSION);
788
- if (!checkGitInstalled()) {
789
- printGitMissing();
790
- if (fromMenu) return;
791
- process.exit(1);
792
- }
793
- let targetDir = process.cwd();
794
- if (options.dir) {
795
- const r = resolveTargetDir(options.dir, process.cwd());
796
- if (!r.ok) {
797
- printErrorBox('THƯ MỤC KHÔNG HỢP LỆ', [r.error]);
798
- if (fromMenu) return;
799
- process.exit(1);
800
- }
801
- targetDir = r.fullPath;
802
- }
803
- if (!branch) branch = getCurrentBranch(targetDir);
804
- if (!isGitRepo(targetDir)) {
805
- printErrorBox('CHƯA PHẢI GIT REPOSITORY', [`Thư mục '${targetDir}' chưa phải Git repository.`]);
806
- if (fromMenu) return;
807
- process.exit(1);
808
- }
809
- if (!branch) {
810
- printErrorBox('KHÔNG XÁC ĐỊNH ĐƯỢC BRANCH', ['Đang ở detached HEAD nên không biết pull branch nào.'], 'Checkout về branch chính rồi chạy lại.');
811
- if (fromMenu) return;
812
- process.exit(1);
813
- }
814
- // Ở chế độ menu luôn hỏi Tiếp tục / Quay lại; ở chế độ lệnh trực tiếp cũng hỏi nếu là terminal tương tác.
815
- const isInteractive = !options.yes;
816
- try {
817
- // Luôn fast-forward để tránh tự tạo merge commit ngoài ý muốn.
818
- printSection(`PULL --ff-only ${remote}/${branch}`);
819
- printTargetDir(targetDir);
820
- const result = pullFromRemote(remote, branch, targetDir);
821
- console.log(chalk.hex('#34D399')(` [OK] ${result || 'Repository đã cập nhật (fast-forward).'}\n`));
822
- if (!fromMenu) printFooter();
823
- } catch (error) {
824
- printErrorBox('PULL THẤT BẠI', [String(error.message).split('\n')[0]], 'Pull --ff-only thất bại khi lịch sử bị lệch. Có thể thử pull --rebase.');
825
- if (!isInteractive) {
826
- if (fromMenu) return;
827
- process.exit(1);
828
- }
829
- const choice = await select({
830
- message: 'Pull fast-forward thất bại. Bạn muốn làm gì tiếp?',
831
- choices: [
832
- { name: '» Tiếp tục: thử pull --rebase', value: 'rebase', description: `git pull --rebase ${remote} ${branch}` },
833
- { name: '« Quay lại menu chính', value: 'back', description: 'Về màn hình chọn hành động' },
834
- { name: 'x Thoát (tự xử lý tay)', value: 'exit', description: 'Không làm gì thêm' }
835
- ]
836
- });
837
- if (choice === 'rebase') {
838
- try {
839
- console.log(chalk.hex('#38BDF8')(`\n » Đang thử: git pull --rebase ${remote} ${branch}...`));
840
- const out = pullRebaseFromRemote(remote, branch, targetDir);
841
- console.log(chalk.hex('#34D399')(` [OK] Pull --rebase thành công! ${String(out).split('\n')[0].slice(0, 100)}\n`));
842
- if (!fromMenu) printFooter();
843
- } catch (rebaseErr) {
844
- printErrorBox('PULL --REBASE THẤT BẠI', [String(rebaseErr.message).split('\n')[0]], 'Có thể bị conflict. Chạy git status để giải quyết rồi pull lại.');
845
- if (!fromMenu) process.exit(1);
846
- }
847
- } else if (choice === 'back') {
848
- if (!fromMenu) {
849
- await runMainMenuLoop();
850
- }
851
- // Nếu đã ở trong menu thì return để caller hỏi Tiếp tục/Quay lại.
852
- return;
853
- } else {
854
- if (!fromMenu) process.exit(1);
855
- return;
856
- }
857
- }
858
- }
228
+ ghCmd
229
+ .command('logout')
230
+ .description('Đăng xuất tài khoản GitHub khỏi máy')
231
+ .action(async () => {
232
+ printBanner(VERSION);
233
+ logoutGitHub();
234
+ });
859
235
 
860
- async function runEditCommitTimeCommand(options = {}) {
861
- const fromMenu = options.fromMenu === true;
862
- if (!fromMenu) printBanner(VERSION);
863
- let targetDir = process.cwd();
864
- if (options.dir) {
865
- const r = resolveTargetDir(options.dir, process.cwd());
866
- if (!r.ok) {
867
- printErrorBox('THƯ MỤC KHÔNG HỢP LỆ', [r.error]);
868
- if (fromMenu) return;
869
- process.exit(1);
870
- }
871
- targetDir = r.fullPath;
872
- }
873
- if (!checkGitInstalled() || !isGitRepo(targetDir)) {
874
- printErrorBox('CHƯA SẴN SÀNG', [`Hãy chạy lệnh trong một Git repository có Git đã cài. (Folder: ${targetDir})`]);
875
- if (fromMenu) return;
876
- process.exit(1);
877
- }
878
- printTargetDir(targetDir);
879
- try {
880
- const commits = getRecentCommits(100, targetDir);
881
- if (commits.length === 0) {
882
- printErrorBox('CHƯA CÓ COMMIT', ['Repository chưa có commit nào.']);
883
- if (fromMenu) return;
884
- process.exit(1);
885
- }
886
- // Bước 1: chọn commit — luôn có nút « Quay lại
887
- let commit = commits[0];
888
- if (commits.length > 1) {
889
- const picked = await selectWithBack({
890
- message: `Chọn commit cần đổi thời gian (${commits.length} commit gần nhất):`,
891
- pageSize: 12,
892
- choices: commits.map((item) => ({
893
- name: `» ${item.date} ${item.shortHash} ${item.subject}`,
894
- value: item,
895
- description: 'Sửa commit này sẽ viết lại nó + các commit phía sau'
896
- }))
897
- });
898
- if (picked === BACK) return; // về menu chính (caller sẽ hỏi Tiếp tục/Quay lại)
899
- commit = picked;
900
- } else {
901
- console.log(chalk.hex('#94A3B8')(` » Chỉ có 1 commit nên tự chọn: ${commit.shortHash} — ${commit.subject}\n`));
902
- }
903
- // Bước 2: chọn kiểu thời gian — luôn có nút « Quay lại
904
- const dateOption = await selectWithBack({
905
- message: 'Chọn thời gian mới: [chọn « Quay lại để đổi commit]',
906
- choices: [
907
- { name: '» Ngay bây giờ', value: 'now', description: 'Dùng thời gian hiện tại' },
908
- { name: '» Nhập thời gian tương đối', value: 'relative', description: 'VD: -2d, -5h, -30m' },
909
- { name: '» Nhập ngày giờ cụ thể', value: 'custom', description: 'VD: 2024-01-15 14:30:00' }
910
- ]
911
- });
912
- if (dateOption === BACK) {
913
- // Quay lại bước chọn commit: gọi đệ quy 1 lần để chọn lại
914
- return await runEditCommitTimeCommand(options);
915
- }
916
- let dateInput = dateOption;
917
- if (dateOption === 'relative') {
918
- const val = await inputWithBack({
919
- message: 'Nhập thời gian (ví dụ: -2d, -5h, -30m):',
920
- default: '-1d',
921
- validate: (value) => {
922
- const parsed = parseCustomDate(value);
923
- return parsed.valid || parsed.error;
924
- }
925
- });
926
- if (val === BACK) return await runEditCommitTimeCommand(options);
927
- dateInput = val;
928
- } else if (dateOption === 'custom') {
929
- const val = await inputWithBack({
930
- message: 'Nhập ngày giờ (YYYY-MM-DD HH:mm:ss):',
931
- validate: (value) => {
932
- const parsed = parseCustomDate(value);
933
- return parsed.valid || parsed.error;
934
- }
935
- });
936
- if (val === BACK) return await runEditCommitTimeCommand(options);
937
- dateInput = val;
938
- }
939
- const parsed = parseCustomDate(dateInput);
940
- if (!parsed.valid) {
941
- printErrorBox('THỜI GIAN KHÔNG HỢP LỆ', [parsed.error], 'VD: now, -2d, -5h, 2024-01-15 14:30:00');
942
- if (fromMenu) return;
943
- process.exit(1);
944
- }
945
- const currentBranch = getCurrentBranch(targetDir);
946
- const result = rewriteCommitDate(commit.hash, parsed.formatted, targetDir);
947
- printSection('ĐÃ ĐỔI THỜI GIAN COMMIT');
948
- console.log(` Branch: ${currentBranch}`);
949
- console.log(` Commit: ${commit.shortHash} — ${commit.subject}`);
950
- console.log(` Thời gian mới: ${parsed.display}`);
951
- console.log(` Đã viết lại ${result.rewrittenCount} commit trên branch hiện tại (hash cũ đã đổi).`);
952
- console.log(` Nhánh backup giữ bản gốc: ${result.backupBranch} (${result.backupRef})`);
953
- console.log(chalk.hex('#FBBF24')(' [!] Nếu commit đã push, cần force push để cập nhật remote.\n'));
954
- } catch (error) {
955
- 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.');
956
- if (fromMenu) return;
957
- process.exit(1);
958
- }
959
- }
236
+ // LỆNH LOGIN / LOGOUT / WHOAMI TRỰC TIẾP
237
+ program
238
+ .command('login')
239
+ .description('Đăng nhập GitHub qua OAuth Device Flow (mở trình duyệt) hoặc Token')
240
+ .action(async () => {
241
+ printBanner(VERSION);
242
+ await promptGitHubLogin();
243
+ });
960
244
 
961
- const program = new Command();
962
- styleCliHelp(program);
245
+ program
246
+ .command('whoami')
247
+ .description('Xem thông tin tài khoản GitHub đang kết nối')
248
+ .action(async () => {
249
+ printBanner(VERSION);
250
+ await showGitHubStatus();
251
+ });
963
252
 
964
253
  program
965
- .name('git-push-time')
966
- .description('Đẩy dự án lên Git siêu tốc + tùy chỉnh thời gian commit (Backdate Commit)')
967
- .version(VERSION)
968
- .option('-r, --repo <url>', 'Đường dẫn Git repository (URL)')
969
- .option('-d, --date <datetime>', 'Thời gian commit (Ví dụ: "2024-01-15 14:30:00", "-2d", "-5h", "now")')
970
- .option('-m, --message <msg>', 'Thông điệp commit (Commit message)')
971
- .option('-b, --branch <branch>', 'Tên branch trên remote (Mặc định: main)')
972
- .option('-C, --dir <path>', 'Folder nguồn cần push (mặc định: thư mục hiện tại; interactive sẽ cho chọn)')
973
- .option('-f, --force', 'Thực hiện push đè (--force)', false)
974
- .option('-y, --yes', 'Bỏ qua prompt tương tác, tự động dùng tham số hoặc giá trị mặc định', false)
975
- .option('--fix', 'Tự động gỡ file secret khỏi stage + thêm .gitignore (an toàn, không xóa file thật)', false)
976
- .action(async (opts) => {
977
- await run(opts);
254
+ .command('logout')
255
+ .description('Đăng xuất tài khoản GitHub khỏi máy')
256
+ .action(async () => {
257
+ printBanner(VERSION);
258
+ logoutGitHub();
978
259
  });
979
260
 
261
+ // LỆNH FIX
980
262
  program
981
263
  .command('fix')
982
- .description('Tự động quét và gỡ file secret khỏi stage, thêm vào .gitignore và làm sạch commit chưa push')
983
- .option('-C, --dir <path>', 'Folder cần fix (mặc định: thư mục hiện tại)')
264
+ .description('Quét & tự động gỡ file secret khỏi stage, cập nhật .gitignore và làm sạch commit unpushed')
265
+ .option('-C, --dir <path>', 'Thư mục cần quét fix (Mặc định: thư mục hiện tại)')
984
266
  .action(async (opts) => {
267
+ printBanner(VERSION);
985
268
  await runFixCommand(opts);
986
269
  });
987
270
 
271
+ // LỆNH PULL
988
272
  program
989
273
  .command('pull [remote] [branch]')
990
- .description('Kéo code từ remote bằng fast-forward --ff-only (mặc định: origin và branch hiện tại)')
991
- .option('-C, --dir <path>', 'Folder repo cần pull (mặc định: thư mục hiện tại)')
274
+ .description('Cập nhật code từ remote bằng fast-forward --ff-only hoặc --rebase')
275
+ .option('-C, --dir <path>', 'Thư mục repo cần pull (Mặc định: thư mục hiện tại)')
992
276
  .action(async (remote, branch, opts) => {
277
+ printBanner(VERSION);
993
278
  await runPullCommand(remote || 'origin', branch, opts || {});
994
279
  });
995
280
 
281
+ // LỆNH EDIT-TIME
996
282
  program
997
283
  .command('edit-time')
998
- .description('Sửa thời gian commit (viết lại hash + descendants, tự tạo nhánh backup, yêu cầu working tree sạch)')
999
- .option('-C, --dir <path>', 'Folder repo cần sửa (mặc định: thư mục hiện tại)')
284
+ .description('Sửa thời gian của commit lịch sử (Backdate commit hash)')
285
+ .option('-C, --dir <path>', 'Thư mục repo (Mặc định: thư mục hiện tại)')
1000
286
  .action(async (opts) => {
287
+ printBanner(VERSION);
1001
288
  await runEditCommitTimeCommand(opts);
1002
289
  });
1003
290