git-push-time 1.1.0 → 1.1.2

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,18 +1,124 @@
1
1
  import { input, select } from '@inquirer/prompts';
2
+ import chalk from 'chalk';
3
+ import { boxTopLeft, boxRow, boxBottom, getW, truncateVisual } from './ui.js';
2
4
 
3
5
  export const BACK = '__back__';
4
6
  export const BACK_TOKENS = new Set(['..', '/back', '<<']);
5
7
 
8
+ const BORDER = chalk.hex('#6366F1');
9
+ const ACCENT = chalk.hex('#38BDF8');
10
+ const DIM = chalk.hex('#64748B');
11
+ const HINT = chalk.hex('#94A3B8');
12
+
6
13
  export function isBackInput(v) {
7
14
  return BACK_TOKENS.has(String(v || '').trim());
8
15
  }
9
16
 
17
+ function cleanTitle(t) {
18
+ return String(t ?? '').trim();
19
+ }
20
+
21
+ function printOpenHeader(title, hintLines = []) {
22
+ console.log('');
23
+ console.log(boxTopLeft(chalk.bold.hex('#F8FAFC')(cleanTitle(title)), BORDER));
24
+ for (const h of hintLines) {
25
+ if (!h) continue;
26
+ console.log(boxRow(HINT(truncateVisual(String(h), getW() - 6)), BORDER));
27
+ }
28
+ }
29
+
30
+ function printCloseWithAnswer(label, value) {
31
+ const W = getW();
32
+ const answer = value === BACK || value === undefined
33
+ ? chalk.hex('#FCD34D')('« Quay lại')
34
+ : chalk.hex('#34D399').bold(truncateVisual(String(value ?? ''), Math.max(10, W - 14)));
35
+ console.log(
36
+ boxRow(
37
+ `${chalk.hex('#34D399')('✓')} ${chalk.hex('#CBD5E1')(truncateVisual(cleanTitle(label), Math.max(8, W - 30)) + ':')} ${answer}`,
38
+ BORDER,
39
+ ),
40
+ );
41
+ console.log(boxBottom(BORDER));
42
+ }
43
+
44
+ function printClosePlain() {
45
+ console.log(boxBottom(BORDER));
46
+ }
47
+
48
+ function shortChoiceName(choices, value) {
49
+ try {
50
+ const found = (choices || []).find((c) => c && c.value === value);
51
+ if (!found) return String(value ?? '');
52
+ // name có thể chứa màu chalk -> strip bằng cách lấy raw rồi cắt
53
+ return String(found.short || found.name || found.value || '').replace(/\u001b\[[0-9;]*m/g, '');
54
+ } catch {
55
+ return String(value ?? '');
56
+ }
57
+ }
58
+
59
+ const baseInputTheme = {
60
+ prefix: { idle: '', done: '' },
61
+ style: {
62
+ message: (t) => t,
63
+ answer: (t) => chalk.hex('#34D399').bold(t),
64
+ defaultAnswer: (t) => DIM(`(${t})`),
65
+ error: (t) => chalk.hex('#FDA4AF')(` x ${t}`),
66
+ help: (t) => DIM(t),
67
+ highlight: (t) => chalk.hex('#38BDF8').bold(t),
68
+ },
69
+ };
70
+
71
+ const baseSelectTheme = {
72
+ prefix: { idle: '', done: '' },
73
+ icon: { cursor: chalk.hex('#38BDF8').bold('❯ ') },
74
+ style: {
75
+ message: (t) => t,
76
+ answer: (t) => chalk.hex('#34D399').bold(t),
77
+ error: (t) => chalk.hex('#FDA4AF')(` x ${t}`),
78
+ help: (t) => DIM(t),
79
+ highlight: (t) => chalk.hex('#38BDF8').bold(t),
80
+ description: (t) => DIM(t),
81
+ },
82
+ };
83
+
84
+ function promptLine(label) {
85
+ // Dòng nhập liệu mang viền trái │ để thị giác nằm gọn trong khung
86
+ // đang mở phía trên (boxTop) và sẽ đóng (boxBottom) sau khi xong.
87
+ return `${BORDER('│')} ${ACCENT('❯')} ${chalk.whiteBright(cleanTitle(label))}`;
88
+ }
89
+
10
90
  /**
11
- * Prompt select có sẵn lựa chọn « Quay lại
12
- * @param {object} params
13
- * @returns {Promise<any>}
91
+ * Select nằm TRONG khung: header mở -> choices -> footer đóng + đáp án.
14
92
  */
15
- export async function selectWithBack({ message, choices, pageSize, ...rest }) {
93
+ export async function selectBox({ message, choices, hint, pageSize, ...rest }) {
94
+ const { clearPromptOnDone: _ignore, theme: customTheme, ...cfg } = rest;
95
+ const hintLines = hint ? (Array.isArray(hint) ? hint : [hint]) : [];
96
+ printOpenHeader(message, hintLines);
97
+
98
+ let value;
99
+ try {
100
+ value = await select(
101
+ {
102
+ message: promptLine('Chọn'),
103
+ choices,
104
+ pageSize: pageSize || Math.min(12, (choices || []).length + 2),
105
+ theme: { ...baseSelectTheme, ...(customTheme || {}) },
106
+ ...cfg,
107
+ },
108
+ { clearPromptOnDone: true },
109
+ );
110
+ } catch (e) {
111
+ printClosePlain();
112
+ throw e;
113
+ }
114
+ printCloseWithAnswer(message, shortChoiceName(choices, value));
115
+ return value;
116
+ }
117
+
118
+ /**
119
+ * Prompt select có sẵn lựa chọn « Quay lại (nằm trong khung).
120
+ */
121
+ export async function selectWithBack({ message, choices, hint, pageSize, ...rest }) {
16
122
  const hasBack = (choices || []).some((c) => c && c.value === BACK);
17
123
  const finalChoices = hasBack
18
124
  ? choices
@@ -20,43 +126,69 @@ export async function selectWithBack({ message, choices, pageSize, ...rest }) {
20
126
  ...choices,
21
127
  { name: '« Quay lại (bước trước)', value: BACK, description: 'Về bước trước / menu chính' }
22
128
  ];
23
- return await select({
24
- message,
25
- choices: finalChoices,
26
- pageSize: pageSize || Math.min(12, finalChoices.length + 2),
27
- ...rest
28
- });
129
+ const backHint = hint ?? '↑↓ di chuyển · Enter chọn · chọn « Quay lại để lùi bước';
130
+ return await selectBox({ message, choices: finalChoices, hint: backHint, pageSize, ...rest });
29
131
  }
30
132
 
31
133
  /**
32
- * Prompt input có hướng dẫn gõ ".." để quay lại
33
- * @param {object} params
34
- * @returns {Promise<string>}
134
+ * Prompt input nằm TRONG khung: header mở -> ô gõ (│ › ...) -> footer + đáp án.
35
135
  */
36
- export async function inputWithBack({ message, validate, ...rest }) {
37
- const hinted = `${message} [gõ ".." để quay lại]`;
136
+ export async function inputBox({ message, promptLabel = 'Nhập', hint, default: def, validate, ...rest }) {
137
+ const { clearPromptOnDone: _ignore, theme: customTheme, ...cfg } = rest;
138
+ const hintLines = [];
139
+ if (hint) hintLines.push(...(Array.isArray(hint) ? hint : [hint]));
140
+ if (def) hintLines.push(`Mặc định: ${def}`);
141
+ printOpenHeader(message, hintLines);
142
+
38
143
  const wrappedValidate = (val) => {
39
144
  if (isBackInput(val)) return true;
40
145
  if (validate) return validate(val);
41
146
  return true;
42
147
  };
43
- const val = await input({ message: hinted, validate: wrappedValidate, ...rest });
44
- if (isBackInput(val)) return BACK;
148
+
149
+ let val;
150
+ try {
151
+ val = await input(
152
+ {
153
+ message: promptLine(promptLabel),
154
+ default: def,
155
+ validate: wrappedValidate,
156
+ theme: { ...baseInputTheme, ...(customTheme || {}) },
157
+ ...cfg,
158
+ },
159
+ { clearPromptOnDone: true },
160
+ );
161
+ } catch (e) {
162
+ printClosePlain();
163
+ throw e;
164
+ }
165
+ if (isBackInput(val)) {
166
+ printCloseWithAnswer(message, BACK);
167
+ return BACK;
168
+ }
169
+ printCloseWithAnswer(message, val || def || '');
45
170
  return val;
46
171
  }
47
172
 
48
173
  /**
49
- * 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.
50
- * @param {string} message
51
- * @returns {Promise<'continue' | 'back' | 'exit'>}
174
+ * Prompt input có hướng dẫn gõ ".." để quay lại (nằm trong khung).
175
+ */
176
+ export async function inputWithBack({ message, hint, validate, ...rest }) {
177
+ const backHint = hint ?? 'Gõ ".." để quay lại bước trước';
178
+ return await inputBox({ message, hint: backHint, validate, ...rest });
179
+ }
180
+
181
+ /**
182
+ * Hỏi Tiếp tục / Quay lại menu / Thoát (nằm trong khung).
52
183
  */
53
184
  export async function promptContinueOrBack(message = 'Bạn muốn tiếp tục hay quay lại?') {
54
- return await select({
185
+ return await selectBox({
55
186
  message,
187
+ hint: 'Chọn bước tiếp theo',
56
188
  choices: [
57
- { name: '» Tiếp tục', value: 'continue', description: 'Đi tiếp bước kế tiếp' },
58
- { name: '« Quay lại menu chính', value: 'back', description: 'Về màn hình chọn hành động' },
59
- { name: 'x Thoát', value: 'exit', description: 'Dừng, không làm gì' }
60
- ]
189
+ { name: 'Tiếp tục', value: 'continue', description: 'Đi tiếp bước kế tiếp' },
190
+ { name: 'Quay lại bảng điều khiển', value: 'back', description: 'Về màn hình chọn hành động' },
191
+ { name: 'Thoát', value: 'exit', description: 'Dừng, không làm gì' }
192
+ ],
61
193
  });
62
194
  }
package/lib/secrets.js CHANGED
@@ -1,87 +1,92 @@
1
- import { execSync } from 'child_process';
2
-
3
- // Pattern quét secret phổ biến. Giữ ở mức precision cao để tránh false-positive.
4
- export const SECRET_PATTERNS = [
5
- { id: 'npm-token', label: 'npm Access Token', regex: /npm_[A-Za-z0-9]{8,}/ },
6
- { id: 'npm-authtoken', label: '_authToken trong .npmrc', regex: /_authToken\s*=\s*\S+/i },
7
- { id: 'github-token', label: 'GitHub Token (ghp_/gho_/github_pat_)', regex: /\b(ghp_[A-Za-z0-9]{20,}|gho_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{40,})\b/ },
8
- { id: 'aws-key', label: 'AWS Access Key (AKIA...)', regex: /\bAKIA[0-9A-Z]{16}\b/ },
9
- { id: 'aws-secret', label: 'AWS Secret Key', regex: /aws_secret_access_key\s*=\s*\S+/i },
10
- { id: 'private-key', label: 'Private Key (PEM)', regex: /-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/ },
11
- { id: 'openai-key', label: 'OpenAI API Key', regex: /\bsk-(proj-)?[A-Za-z0-9_-]{20,}\b/ },
12
- { id: 'slack-token', label: 'Slack Token', regex: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ },
13
- { id: 'google-key', label: 'Google API Key', regex: /\bAIza[0-9A-Za-z_-]{30,}\b/ },
14
- { id: 'generic-password', label: 'Password gán cứng', regex: /\b(password|passwd|pwd)\s*[:=]\s*['"][^'"]{4,}['"]/i },
15
- ];
16
-
17
- const SKIP_DIRS = ['node_modules/', '.git/', 'dist/', 'build/', '.next/'];
18
- const SKIP_FILES = ['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml'];
19
- const MAX_FILE_BYTES = 512 * 1024; // bỏ qua file staged > 512KB
20
- const MAX_MATCHES_PER_FILE = 3;
21
-
22
- function listStagedFiles(cwd) {
23
- try {
24
- const out = execSync('git diff --cached --name-only --diff-filter=ACM', {
25
- cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore']
26
- });
27
- return out.split('\n').map((s) => s.trim()).filter(Boolean);
28
- } catch {
29
- return [];
30
- }
31
- }
32
-
33
- function readStagedContent(file, cwd) {
34
- try {
35
- // Đọc nội dung đã stage (git show :path), không phải file ngoài disk
36
- const content = execSync(`git show :"${file.replace(/"/g, '')}"`, {
37
- cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: MAX_FILE_BYTES * 2
38
- });
39
- if (content.length > MAX_FILE_BYTES) return null; // file quá lớn, bỏ qua
40
- if (content.includes('\0')) return null; // file binary
41
- return content;
42
- } catch {
43
- return null;
44
- }
45
- }
46
-
47
- function redactPreview(line) {
48
- const s = line.trim().slice(0, 120);
49
- // Che giữa secret, chỉ chừa 4 ký tự đầu/cuối
50
- return s.replace(/([A-Za-z0-9_.-]{4})[A-Za-z0-9_.-]{6,}([A-Za-z0-9_.-]{2})?/g, '$1•••$2');
51
- }
52
-
53
- /**
54
- * Quét nội dung các file đã stage để tìm secret.
55
- * @param {string} cwd
56
- * @returns {{ file: string, type: string, line: number, preview: string }[]}
57
- */
58
- export function scanStagedContentForSecrets(cwd = process.cwd()) {
59
- const findings = [];
60
- const files = listStagedFiles(cwd).filter((f) => {
61
- if (SKIP_DIRS.some((d) => f.startsWith(d))) return false;
62
- if (SKIP_FILES.includes(f.split('/').pop())) return false;
63
- return true;
64
- });
65
-
66
- for (const file of files.slice(0, 100)) { // giới hạn 100 file để nhanh
67
- const content = readStagedContent(file, cwd);
68
- if (!content) continue;
69
- const lines = content.split('\n');
70
- let matchesInFile = 0;
71
- for (let i = 0; i < lines.length; i++) {
72
- const line = lines[i];
73
- if (!line || line.length > 2000) continue;
74
- // Bỏ qua comment ví dụ trong docs? vẫn báo để user tự quyết
75
- for (const p of SECRET_PATTERNS) {
76
- if (p.regex.test(line)) {
77
- findings.push({ file, type: p.label, line: i + 1, preview: redactPreview(line) });
78
- matchesInFile++;
79
- break; // mỗi dòng chỉ báo 1 lần
80
- }
81
- }
82
- if (matchesInFile >= MAX_MATCHES_PER_FILE) break;
83
- if (findings.length >= 20) return findings; // chặn spam
84
- }
85
- }
86
- return findings;
87
- }
1
+ import { spawnSync } from 'child_process';
2
+
3
+ // Pattern quét secret phổ biến. Giữ ở mức precision cao để tránh false-positive.
4
+ export const SECRET_PATTERNS = [
5
+ { id: 'npm-token', label: 'npm Access Token', regex: /npm_[A-Za-z0-9]{8,}/ },
6
+ { id: 'npm-authtoken', label: '_authToken trong .npmrc', regex: /_authToken\s*=\s*\S+/i },
7
+ { id: 'github-token', label: 'GitHub Token (ghp_/gho_/github_pat_)', regex: /\b(ghp_[A-Za-z0-9]{20,}|gho_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{40,})\b/ },
8
+ { id: 'aws-key', label: 'AWS Access Key (AKIA...)', regex: /\bAKIA[0-9A-Z]{16}\b/ },
9
+ { id: 'aws-secret', label: 'AWS Secret Key', regex: /aws_secret_access_key\s*=\s*\S+/i },
10
+ { id: 'private-key', label: 'Private Key (PEM)', regex: /-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/ },
11
+ { id: 'openai-key', label: 'OpenAI API Key', regex: /\bsk-(proj-)?[A-Za-z0-9_-]{20,}\b/ },
12
+ { id: 'slack-token', label: 'Slack Token', regex: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ },
13
+ { id: 'google-key', label: 'Google API Key', regex: /\bAIza[0-9A-Za-z_-]{30,}\b/ },
14
+ { id: 'generic-password', label: 'Password gán cứng', regex: /\b(password|passwd|pwd)\s*[:=]\s*['"][^'"]{4,}['"]/i },
15
+ ];
16
+
17
+ const SKIP_DIRS = ['node_modules/', '.git/', 'dist/', 'build/', '.next/'];
18
+ const SKIP_FILES = ['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml'];
19
+ const MAX_FILE_BYTES = 512 * 1024; // bỏ qua file staged > 512KB
20
+ const MAX_MATCHES_PER_FILE = 3;
21
+
22
+ function listStagedFiles(cwd) {
23
+ try {
24
+ const r = spawnSync('git', ['diff', '--cached', '--name-only', '--diff-filter=ACM'], {
25
+ cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore']
26
+ });
27
+ if (r.status !== 0) return [];
28
+ return (r.stdout || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
29
+ } catch {
30
+ return [];
31
+ }
32
+ }
33
+
34
+ function readStagedContent(file, cwd) {
35
+ try {
36
+ // Dùng spawnSync dạng mảng args (không qua shell) để tránh injection
37
+ // khi tên file chứa ký tự đặc biệt (", $, ;, &, |, ...).
38
+ const r = spawnSync('git', ['show', `:${file}`], {
39
+ cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: MAX_FILE_BYTES * 2
40
+ });
41
+ if (r.status !== 0) return null;
42
+ const content = r.stdout || '';
43
+ if (content.length > MAX_FILE_BYTES) return null; // file quá lớn, bỏ qua
44
+ if (content.includes('\0')) return null; // file binary
45
+ return content;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ function redactPreview(line) {
52
+ const s = line.trim().slice(0, 120);
53
+ // Che giữa secret, chỉ chừa 4 ký tự đầu/cuối
54
+ return s.replace(/([A-Za-z0-9_.-]{4})[A-Za-z0-9_.-]{6,}([A-Za-z0-9_.-]{2})?/g, '$1•••$2');
55
+ }
56
+
57
+ /**
58
+ * Quét nội dung các file đã stage để tìm secret.
59
+ * @param {string} cwd
60
+ * @returns {{ file: string, type: string, line: number, preview: string }[]}
61
+ */
62
+ export function scanStagedContentForSecrets(cwd = process.cwd()) {
63
+ const findings = [];
64
+ const files = listStagedFiles(cwd).filter((f) => {
65
+ const normalized = String(f).replace(/\\/g, '/');
66
+ if (SKIP_DIRS.some((d) => normalized.startsWith(d) || normalized.includes('/' + d))) return false;
67
+ if (SKIP_FILES.includes(normalized.split('/').pop())) return false;
68
+ return true;
69
+ });
70
+
71
+ for (const file of files.slice(0, 100)) { // giới hạn 100 file để nhanh
72
+ const content = readStagedContent(file, cwd);
73
+ if (!content) continue;
74
+ const lines = content.split('\n');
75
+ let matchesInFile = 0;
76
+ for (let i = 0; i < lines.length; i++) {
77
+ const line = lines[i];
78
+ if (!line || line.length > 2000) continue;
79
+ // Bỏ qua comment ví dụ trong docs? vẫn báo để user tự quyết
80
+ for (const p of SECRET_PATTERNS) {
81
+ if (p.regex.test(line)) {
82
+ findings.push({ file, type: p.label, line: i + 1, preview: redactPreview(line) });
83
+ matchesInFile++;
84
+ break; // mỗi dòng chỉ báo 1 lần
85
+ }
86
+ }
87
+ if (matchesInFile >= MAX_MATCHES_PER_FILE) break;
88
+ if (findings.length >= 20) return findings; // chặn spam
89
+ }
90
+ }
91
+ return findings;
92
+ }