git-push-time 1.0.2 → 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/lib/github.js ADDED
@@ -0,0 +1,304 @@
1
+ import { spawn } from 'child_process';
2
+ import { getGitHubToken, saveGitHubToken } from './config.js';
3
+
4
+ const GITHUB_API_BASE = 'https://api.github.com';
5
+ const GITHUB_OAUTH_CLIENT_ID = '178c6fc778ccc68e1d6a'; // Standard CLI OAuth Client ID (gh cli)
6
+
7
+ /**
8
+ * Mở URL trên trình duyệt web mặc định của hệ thống
9
+ * @param {string} url
10
+ */
11
+ export function openBrowser(url) {
12
+ try {
13
+ const platform = process.platform;
14
+ if (platform === 'win32') {
15
+ spawn('cmd', ['/c', 'start', '""', url], { detached: true, stdio: 'ignore' });
16
+ } else if (platform === 'darwin') {
17
+ spawn('open', [url], { detached: true, stdio: 'ignore' });
18
+ } else {
19
+ spawn('xdg-open', [url], { detached: true, stdio: 'ignore' });
20
+ }
21
+ return true;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Khởi tạo GitHub OAuth Device Authorization Flow
29
+ * @returns {Promise<{ ok: boolean, data?: { device_code: string, user_code: string, verification_uri: string, expires_in: number, interval: number }, error?: string }>}
30
+ */
31
+ export async function initiateDeviceFlow() {
32
+ try {
33
+ const res = await fetch('https://github.com/login/device/code', {
34
+ method: 'POST',
35
+ headers: {
36
+ 'Accept': 'application/json',
37
+ 'Content-Type': 'application/json'
38
+ },
39
+ body: JSON.stringify({
40
+ client_id: GITHUB_OAUTH_CLIENT_ID,
41
+ scope: 'repo read:org gist'
42
+ })
43
+ });
44
+
45
+ if (!res.ok) {
46
+ const err = await res.json().catch(() => ({}));
47
+ return { ok: false, error: err.error_description || err.message || `Lỗi API (${res.status})` };
48
+ }
49
+
50
+ const data = await res.json();
51
+ return { ok: true, data };
52
+ } catch (err) {
53
+ return { ok: false, error: `Lỗi kết nối mạng: ${err.message}` };
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Polling kiểm tra người dùng đã Authorize trên GitHub chưa
59
+ * @param {string} deviceCode
60
+ * @param {number} intervalSeconds
61
+ * @param {number} expiresInSeconds
62
+ * @param {(status: string) => void} onStatusUpdate
63
+ * @returns {Promise<{ ok: boolean, token?: string, error?: string }>}
64
+ */
65
+ export async function pollDeviceToken(deviceCode, intervalSeconds = 5, expiresInSeconds = 900, onStatusUpdate = null) {
66
+ let interval = Math.max(2, intervalSeconds);
67
+ const startTime = Date.now();
68
+ const maxTime = expiresInSeconds * 1000;
69
+
70
+ while (Date.now() - startTime < maxTime) {
71
+ await new Promise((resolve) => setTimeout(resolve, interval * 1000));
72
+
73
+ try {
74
+ const res = await fetch('https://github.com/login/oauth/access_token', {
75
+ method: 'POST',
76
+ headers: {
77
+ 'Accept': 'application/json',
78
+ 'Content-Type': 'application/json'
79
+ },
80
+ body: JSON.stringify({
81
+ client_id: GITHUB_OAUTH_CLIENT_ID,
82
+ device_code: deviceCode,
83
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
84
+ })
85
+ });
86
+
87
+ const data = await res.json();
88
+
89
+ if (data.access_token) {
90
+ return { ok: true, token: data.access_token };
91
+ }
92
+
93
+ if (data.error === 'authorization_pending') {
94
+ if (onStatusUpdate) onStatusUpdate('pending');
95
+ continue;
96
+ }
97
+
98
+ if (data.error === 'slow_down') {
99
+ interval = (data.interval || interval) + 5;
100
+ if (onStatusUpdate) onStatusUpdate('slow_down');
101
+ continue;
102
+ }
103
+
104
+ if (data.error === 'expired_token') {
105
+ return { ok: false, error: 'Mã xác thực đã hết hạn. Vui lòng thử lại.' };
106
+ }
107
+
108
+ if (data.error === 'access_denied') {
109
+ return { ok: false, error: 'Bạn đã từ chối cấp quyền trên GitHub.' };
110
+ }
111
+
112
+ return { ok: false, error: data.error_description || data.error || 'Lỗi xác thực không xác định' };
113
+ } catch (err) {
114
+ // Tiếp tục thử lại nếu lỗi mạng tạm thời
115
+ continue;
116
+ }
117
+ }
118
+
119
+ return { ok: false, error: 'Hết thời gian chờ cấp quyền (Timeout).' };
120
+ }
121
+
122
+ /**
123
+ * Tạo headers chuẩn cho GitHub REST API
124
+ * @param {string} token
125
+ * @returns {Record<string, string>}
126
+ */
127
+ function getApiHeaders(token) {
128
+ return {
129
+ 'Accept': 'application/vnd.github+json',
130
+ 'Authorization': `Bearer ${token}`,
131
+ 'X-GitHub-Api-Version': '2022-11-28',
132
+ 'User-Agent': 'git-push-time-cli'
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Kiểm tra token và lấy thông tin tài khoản GitHub
138
+ * @param {string} token
139
+ * @returns {Promise<{ ok: boolean, user?: any, error?: string }>}
140
+ */
141
+ export async function verifyAndFetchUser(token) {
142
+ if (!token || !token.trim()) {
143
+ return { ok: false, error: 'Token không được để trống.' };
144
+ }
145
+
146
+ try {
147
+ const res = await fetch(`${GITHUB_API_BASE}/user`, {
148
+ method: 'GET',
149
+ headers: getApiHeaders(token.trim())
150
+ });
151
+
152
+ if (res.status === 401) {
153
+ return { ok: false, error: 'Token không hợp lệ hoặc đã hết hạn (401 Unauthorized).' };
154
+ }
155
+
156
+ if (!res.ok) {
157
+ const errData = await res.json().catch(() => ({}));
158
+ return { ok: false, error: errData.message || `Lỗi kết nối GitHub API (${res.status})` };
159
+ }
160
+
161
+ const user = await res.json();
162
+ return { ok: true, user };
163
+ } catch (err) {
164
+ return { ok: false, error: `Lỗi kết nối mạng: ${err.message}` };
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Lấy thông tin user hiện tại từ token đang kích hoạt
170
+ * @returns {Promise<{ authenticated: boolean, user?: any, source?: string, token?: string, error?: string }>}
171
+ */
172
+ export async function getCurrentGitHubUser() {
173
+ const { token, source, user: cachedUser } = getGitHubToken();
174
+ if (!token) {
175
+ return { authenticated: false, error: 'Chưa kết nối tài khoản GitHub' };
176
+ }
177
+
178
+ // Thử verify với API
179
+ const check = await verifyAndFetchUser(token);
180
+ if (!check.ok) {
181
+ // Nếu lỗi mạng nhưng có cache user
182
+ if (cachedUser) {
183
+ return { authenticated: true, user: cachedUser, token, source, offline: true };
184
+ }
185
+ return { authenticated: false, error: check.error, source };
186
+ }
187
+
188
+ // Cập nhật lại cache user vào config
189
+ if (source === 'config') {
190
+ saveGitHubToken(token, check.user);
191
+ }
192
+
193
+ return { authenticated: true, user: check.user, token, source };
194
+ }
195
+
196
+ /**
197
+ * Lấy danh sách repository của user hiện tại
198
+ * @param {object} options
199
+ * @param {number} options.limit Số lượng repo tối đa
200
+ * @param {'all' | 'owner' | 'public' | 'private'} options.type
201
+ * @returns {Promise<{ ok: boolean, repos?: any[], error?: string }>}
202
+ */
203
+ export async function listUserRepos({ limit = 30, type = 'owner' } = {}) {
204
+ const { token } = getGitHubToken();
205
+ if (!token) {
206
+ return { ok: false, error: 'Chưa có GitHub Token. Vui lòng kết nối tài khoản trước.' };
207
+ }
208
+
209
+ try {
210
+ const url = new URL(`${GITHUB_API_BASE}/user/repos`);
211
+ url.searchParams.set('sort', 'updated');
212
+ url.searchParams.set('direction', 'desc');
213
+ url.searchParams.set('per_page', String(Math.min(100, Math.max(1, limit))));
214
+ url.searchParams.set('type', type);
215
+
216
+ const res = await fetch(url.toString(), {
217
+ method: 'GET',
218
+ headers: getApiHeaders(token)
219
+ });
220
+
221
+ if (!res.ok) {
222
+ const errData = await res.json().catch(() => ({}));
223
+ return { ok: false, error: errData.message || `Không thể tải danh sách repo (${res.status})` };
224
+ }
225
+
226
+ const repos = await res.json();
227
+ return { ok: true, repos };
228
+ } catch (err) {
229
+ return { ok: false, error: `Lỗi kết nối: ${err.message}` };
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Tạo repository mới trên GitHub
235
+ * @param {object} params
236
+ * @param {string} params.name Tên repo (bắt buộc)
237
+ * @param {string} [params.description] Mô tả repo
238
+ * @param {boolean} [params.isPrivate] Repo riêng tư hay công khai
239
+ * @param {boolean} [params.autoInit] Có tự tạo README.md ban đầu không (mặc định false để push project có sẵn)
240
+ * @returns {Promise<{ ok: boolean, repo?: any, error?: string }>}
241
+ */
242
+ export async function createGitHubRepo({ name, description = '', isPrivate = false, autoInit = false }) {
243
+ const { token } = getGitHubToken();
244
+ if (!token) {
245
+ return { ok: false, error: 'Chưa có GitHub Token. Vui lòng kết nối tài khoản trước.' };
246
+ }
247
+
248
+ const cleanName = String(name || '').trim();
249
+ if (!cleanName) {
250
+ return { ok: false, error: 'Tên repository không được để trống.' };
251
+ }
252
+
253
+ if (!/^[a-zA-Z0-9_.-]+$/.test(cleanName)) {
254
+ return { ok: false, error: 'Tên repo chỉ được chứa chữ cái, số, dấu gạch nối (-), gạch dưới (_), hoặc dấu chấm (.).' };
255
+ }
256
+
257
+ try {
258
+ const payload = {
259
+ name: cleanName,
260
+ description: (description || '').trim(),
261
+ private: Boolean(isPrivate),
262
+ auto_init: Boolean(autoInit)
263
+ };
264
+
265
+ const res = await fetch(`${GITHUB_API_BASE}/user/repos`, {
266
+ method: 'POST',
267
+ headers: {
268
+ ...getApiHeaders(token),
269
+ 'Content-Type': 'application/json'
270
+ },
271
+ body: JSON.stringify(payload)
272
+ });
273
+
274
+ if (res.status === 422) {
275
+ const errData = await res.json().catch(() => ({}));
276
+ const msg = (errData.errors && errData.errors[0]?.message) || errData.message || 'Tên repository đã tồn tại hoặc không hợp lệ.';
277
+ return { ok: false, error: `Không thể tạo repo: ${msg}` };
278
+ }
279
+
280
+ if (!res.ok) {
281
+ const errData = await res.json().catch(() => ({}));
282
+ return { ok: false, error: errData.message || `Lỗi từ GitHub API (${res.status})` };
283
+ }
284
+
285
+ const repo = await res.json();
286
+ return {
287
+ ok: true,
288
+ repo: {
289
+ id: repo.id,
290
+ name: repo.name,
291
+ fullName: repo.full_name,
292
+ isPrivate: repo.private,
293
+ description: repo.description || '',
294
+ htmlUrl: repo.html_url,
295
+ cloneUrl: repo.clone_url,
296
+ sshUrl: repo.ssh_url,
297
+ defaultBranch: repo.default_branch || 'main',
298
+ createdAt: repo.created_at
299
+ }
300
+ };
301
+ } catch (err) {
302
+ return { ok: false, error: `Lỗi kết nối khi tạo repo: ${err.message}` };
303
+ }
304
+ }
@@ -0,0 +1,62 @@
1
+ import { input, select } from '@inquirer/prompts';
2
+
3
+ export const BACK = '__back__';
4
+ export const BACK_TOKENS = new Set(['..', '/back', '<<']);
5
+
6
+ export function isBackInput(v) {
7
+ return BACK_TOKENS.has(String(v || '').trim());
8
+ }
9
+
10
+ /**
11
+ * Prompt select có sẵn lựa chọn « Quay lại
12
+ * @param {object} params
13
+ * @returns {Promise<any>}
14
+ */
15
+ export async function selectWithBack({ message, choices, pageSize, ...rest }) {
16
+ const hasBack = (choices || []).some((c) => c && c.value === BACK);
17
+ const finalChoices = hasBack
18
+ ? choices
19
+ : [
20
+ ...choices,
21
+ { name: '« Quay lại (bước trước)', value: BACK, description: 'Về bước trước / menu chính' }
22
+ ];
23
+ return await select({
24
+ message,
25
+ choices: finalChoices,
26
+ pageSize: pageSize || Math.min(12, finalChoices.length + 2),
27
+ ...rest
28
+ });
29
+ }
30
+
31
+ /**
32
+ * Prompt input có hướng dẫn gõ ".." để quay lại
33
+ * @param {object} params
34
+ * @returns {Promise<string>}
35
+ */
36
+ export async function inputWithBack({ message, validate, ...rest }) {
37
+ const hinted = `${message} [gõ ".." để quay lại]`;
38
+ const wrappedValidate = (val) => {
39
+ if (isBackInput(val)) return true;
40
+ if (validate) return validate(val);
41
+ return true;
42
+ };
43
+ const val = await input({ message: hinted, validate: wrappedValidate, ...rest });
44
+ if (isBackInput(val)) return BACK;
45
+ return val;
46
+ }
47
+
48
+ /**
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'>}
52
+ */
53
+ export async function promptContinueOrBack(message = 'Bạn muốn tiếp tục hay quay lại?') {
54
+ return await select({
55
+ message,
56
+ 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
+ ]
61
+ });
62
+ }