@wangjs-jacky/ticktick-cli 0.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/.github/workflows/npm-publish.yml +26 -0
- package/CLAUDE.md +34 -0
- package/README.md +62 -0
- package/README_CN.md +62 -0
- package/bin/cli.ts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1490 -0
- package/dist/index.js.map +1 -0
- package/docs/oauth-credential-pre-validation.md +253 -0
- package/docs/reference/cli-usage-guide.md +587 -0
- package/docs/reference/dida365-open-api-zh.md +999 -0
- package/docs/reference/dida365-open-api.md +999 -0
- package/docs/reference/project-guide.md +63 -0
- package/docs/superpowers/plans/2026-04-03-tt-cli-auth.md +1110 -0
- package/docs/superpowers/specs/2026-04-03-tt-cli-design.md +142 -0
- package/package.json +45 -0
- package/skills/tt-cli-guide/SKILL.md +152 -0
- package/skills/tt-cli-guide/references/intent-mapping.md +169 -0
- package/src/api/client.ts +61 -0
- package/src/api/oauth.ts +146 -0
- package/src/api/resources.ts +291 -0
- package/src/commands/auth.ts +218 -0
- package/src/commands/project.ts +303 -0
- package/src/commands/task.ts +806 -0
- package/src/commands/user.ts +43 -0
- package/src/index.ts +46 -0
- package/src/types.ts +211 -0
- package/src/utils/config.ts +88 -0
- package/src/utils/endpoints.ts +22 -0
- package/src/utils/format.ts +71 -0
- package/src/utils/server.ts +81 -0
- package/tests/config.test.ts +87 -0
- package/tests/format.test.ts +56 -0
- package/tests/oauth.test.ts +42 -0
- package/tests/parity-fields.test.ts +89 -0
- package/tests/parity-map.ts +184 -0
- package/tests/parity.test.ts +101 -0
- package/tsconfig.json +22 -0
- package/tsup.config.ts +12 -0
- package/vitest.config.ts +7 -0
package/src/api/oauth.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import open from 'open';
|
|
3
|
+
import type { OAuthConfig, TokenData, TokenResponse } from '../types.js';
|
|
4
|
+
import { getOAuth, getToken, setToken, getRegion } from '../utils/config.js';
|
|
5
|
+
import { getEndpoints } from '../utils/endpoints.js';
|
|
6
|
+
import { createCallbackServer } from '../utils/server.js';
|
|
7
|
+
|
|
8
|
+
const SCOPES = 'tasks:read tasks:write';
|
|
9
|
+
const DEFAULT_PORT = 3000;
|
|
10
|
+
|
|
11
|
+
/** 生成随机 state(防 CSRF) */
|
|
12
|
+
export function generateState(): string {
|
|
13
|
+
return crypto.randomBytes(16).toString('hex');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 构建授权 URL */
|
|
17
|
+
export function buildAuthUrl(config: OAuthConfig, state: string, port: number, region: 'cn' | 'global' = 'cn'): string {
|
|
18
|
+
const endpoints = getEndpoints(region);
|
|
19
|
+
const redirectUri = `http://localhost:${port}/callback`;
|
|
20
|
+
const params = new URLSearchParams({
|
|
21
|
+
client_id: config.clientId,
|
|
22
|
+
response_type: 'code',
|
|
23
|
+
redirect_uri: redirectUri,
|
|
24
|
+
scope: SCOPES,
|
|
25
|
+
state,
|
|
26
|
+
});
|
|
27
|
+
return `${endpoints.authUrl}?${params.toString()}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 用授权码换取 Token */
|
|
31
|
+
export async function exchangeCode(
|
|
32
|
+
config: OAuthConfig,
|
|
33
|
+
code: string,
|
|
34
|
+
port: number
|
|
35
|
+
): Promise<TokenData> {
|
|
36
|
+
const region = getRegion();
|
|
37
|
+
const endpoints = getEndpoints(region);
|
|
38
|
+
const redirectUri = `http://localhost:${port}/callback`;
|
|
39
|
+
const credentials = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString('base64');
|
|
40
|
+
|
|
41
|
+
const response = await fetch(endpoints.tokenUrl, {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: {
|
|
44
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
45
|
+
'Authorization': `Basic ${credentials}`,
|
|
46
|
+
},
|
|
47
|
+
body: new URLSearchParams({
|
|
48
|
+
code,
|
|
49
|
+
grant_type: 'authorization_code',
|
|
50
|
+
redirect_uri: redirectUri,
|
|
51
|
+
scope: SCOPES,
|
|
52
|
+
}).toString(),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
const text = await response.text();
|
|
57
|
+
throw new Error(`Token 交换失败: ${response.status} ${text}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const data = (await response.json()) as TokenResponse;
|
|
61
|
+
return {
|
|
62
|
+
accessToken: data.access_token,
|
|
63
|
+
refreshToken: data.refresh_token,
|
|
64
|
+
expiresAt: Date.now() + data.expires_in * 1000,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 刷新 Token */
|
|
69
|
+
export async function refreshAccessToken(): Promise<TokenData> {
|
|
70
|
+
const oauth = getOAuth();
|
|
71
|
+
const token = getToken();
|
|
72
|
+
const region = getRegion();
|
|
73
|
+
const endpoints = getEndpoints(region);
|
|
74
|
+
|
|
75
|
+
if (!oauth || !token) {
|
|
76
|
+
throw new Error('未登录,请先运行 tt login');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const credentials = Buffer.from(`${oauth.clientId}:${oauth.clientSecret}`).toString('base64');
|
|
80
|
+
|
|
81
|
+
const response = await fetch(endpoints.tokenUrl, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: {
|
|
84
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
85
|
+
'Authorization': `Basic ${credentials}`,
|
|
86
|
+
},
|
|
87
|
+
body: new URLSearchParams({
|
|
88
|
+
grant_type: 'refresh_token',
|
|
89
|
+
refresh_token: token.refreshToken,
|
|
90
|
+
}).toString(),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
const text = await response.text();
|
|
95
|
+
throw new Error(`Token 刷新失败: ${response.status} ${text}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const data = (await response.json()) as TokenResponse;
|
|
99
|
+
const newToken: TokenData = {
|
|
100
|
+
accessToken: data.access_token,
|
|
101
|
+
refreshToken: data.refresh_token,
|
|
102
|
+
expiresAt: Date.now() + data.expires_in * 1000,
|
|
103
|
+
};
|
|
104
|
+
setToken(newToken);
|
|
105
|
+
return newToken;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 完整登录流程 */
|
|
109
|
+
export async function loginWithBrowser(config: OAuthConfig, port = DEFAULT_PORT): Promise<TokenData> {
|
|
110
|
+
const region = getRegion();
|
|
111
|
+
const state = generateState();
|
|
112
|
+
const authUrl = buildAuthUrl(config, state, port, region);
|
|
113
|
+
|
|
114
|
+
// 先启动服务器
|
|
115
|
+
const codePromise = createCallbackServer(state, port);
|
|
116
|
+
|
|
117
|
+
// 再打开浏览器
|
|
118
|
+
await open(authUrl);
|
|
119
|
+
|
|
120
|
+
// 等待回调(含超时处理)
|
|
121
|
+
let callbackResult: { code: string; close: () => void };
|
|
122
|
+
try {
|
|
123
|
+
callbackResult = await codePromise;
|
|
124
|
+
} catch (err) {
|
|
125
|
+
if ((err as Error).message === 'TIMEOUT') {
|
|
126
|
+
throw new Error(
|
|
127
|
+
'登录超时(2 分钟未收到授权回调)。\n' +
|
|
128
|
+
'可能原因:\n' +
|
|
129
|
+
' 1. 浏览器授权页面显示了错误(如 invalid_client)\n' +
|
|
130
|
+
' 2. 凭证与当前区域不匹配\n' +
|
|
131
|
+
` 当前区域:${region === 'cn' ? '国内版(dida365.com)' : '国际版(ticktick.com)'}\n` +
|
|
132
|
+
'建议:检查浏览器页面错误,或尝试切换区域/重新配置凭证'
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 交换 token
|
|
139
|
+
const token = await exchangeCode(config, callbackResult.code, port);
|
|
140
|
+
setToken(token);
|
|
141
|
+
|
|
142
|
+
// 关闭服务器
|
|
143
|
+
callbackResult.close();
|
|
144
|
+
|
|
145
|
+
return token;
|
|
146
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { apiRequest } from './client.js';
|
|
2
|
+
import type {
|
|
3
|
+
Project,
|
|
4
|
+
ProjectData,
|
|
5
|
+
Task,
|
|
6
|
+
CreateProjectParams,
|
|
7
|
+
UpdateProjectParams,
|
|
8
|
+
CreateTaskParams,
|
|
9
|
+
UpdateTaskParams,
|
|
10
|
+
MoveTaskParams,
|
|
11
|
+
CompletedTasksParams,
|
|
12
|
+
FilterTasksParams,
|
|
13
|
+
BatchTasksParams,
|
|
14
|
+
UserPreference,
|
|
15
|
+
UndoneTasksParams,
|
|
16
|
+
} from '../types.js';
|
|
17
|
+
|
|
18
|
+
// ─── Project API ─────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/** 获取所有项目 */
|
|
21
|
+
export function getProjects(): Promise<Project[]> {
|
|
22
|
+
return apiRequest<Project[]>('project');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 获取单个项目 */
|
|
26
|
+
export function getProject(projectId: string): Promise<Project> {
|
|
27
|
+
return apiRequest<Project>(`project/${projectId}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 获取项目及任务数据 */
|
|
31
|
+
export function getProjectData(projectId: string): Promise<ProjectData> {
|
|
32
|
+
return apiRequest<ProjectData>(`project/${projectId}/data`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 创建项目 */
|
|
36
|
+
export function createProject(data: CreateProjectParams): Promise<Project> {
|
|
37
|
+
return apiRequest<Project>('project', {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
body: JSON.stringify(data),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 更新项目 */
|
|
44
|
+
export function updateProject(
|
|
45
|
+
projectId: string,
|
|
46
|
+
data: UpdateProjectParams
|
|
47
|
+
): Promise<Project> {
|
|
48
|
+
return apiRequest<Project>(`project/${projectId}`, {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
body: JSON.stringify(data),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 删除项目 */
|
|
55
|
+
export function deleteProject(projectId: string): Promise<void> {
|
|
56
|
+
return apiRequest<void>(`project/${projectId}`, { method: 'DELETE' });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── Task API ────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/** 获取任务 */
|
|
62
|
+
export function getTask(projectId: string, taskId: string): Promise<Task> {
|
|
63
|
+
return apiRequest<Task>(`project/${projectId}/task/${taskId}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 创建任务 */
|
|
67
|
+
export function createTask(data: CreateTaskParams): Promise<Task> {
|
|
68
|
+
return apiRequest<Task>('task', {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
body: JSON.stringify(data),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 更新任务 */
|
|
75
|
+
export function updateTask(
|
|
76
|
+
taskId: string,
|
|
77
|
+
data: UpdateTaskParams
|
|
78
|
+
): Promise<Task> {
|
|
79
|
+
return apiRequest<Task>(`task/${taskId}`, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
body: JSON.stringify(data),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 完成任务 */
|
|
86
|
+
export function completeTask(
|
|
87
|
+
projectId: string,
|
|
88
|
+
taskId: string
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
return apiRequest<void>(`project/${projectId}/task/${taskId}/complete`, {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 删除任务 */
|
|
96
|
+
export function deleteTask(
|
|
97
|
+
projectId: string,
|
|
98
|
+
taskId: string
|
|
99
|
+
): Promise<void> {
|
|
100
|
+
return apiRequest<void>(`project/${projectId}/task/${taskId}`, {
|
|
101
|
+
method: 'DELETE',
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 移动任务 */
|
|
106
|
+
export function moveTasks(
|
|
107
|
+
moves: MoveTaskParams[]
|
|
108
|
+
): Promise<Array<{ id: string; etag: string }>> {
|
|
109
|
+
return apiRequest<Array<{ id: string; etag: string }>>('task/move', {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
body: JSON.stringify(moves),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 获取已完成任务 */
|
|
116
|
+
export function getCompletedTasks(
|
|
117
|
+
params: CompletedTasksParams
|
|
118
|
+
): Promise<Task[]> {
|
|
119
|
+
return apiRequest<Task[]>('task/completed', {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
body: JSON.stringify(params),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 筛选任务 */
|
|
126
|
+
export function filterTasks(params: FilterTasksParams): Promise<Task[]> {
|
|
127
|
+
return apiRequest<Task[]>('task/filter', {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
body: JSON.stringify(params),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ─── 批量操作 ──────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
/** 批量添加任务(API 返回 { id2etag: {...} },不是 Task[]) */
|
|
136
|
+
export async function batchAddTasks(
|
|
137
|
+
tasks: CreateTaskParams[]
|
|
138
|
+
): Promise<{ count: number; id2etag?: Record<string, string> }> {
|
|
139
|
+
const res = await apiRequest<Record<string, unknown>>('task/batch', {
|
|
140
|
+
method: 'POST',
|
|
141
|
+
body: JSON.stringify({ add: tasks }),
|
|
142
|
+
});
|
|
143
|
+
return {
|
|
144
|
+
count: tasks.length,
|
|
145
|
+
id2etag: (res?.id2etag ?? res) as Record<string, string> | undefined,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 批量更新任务 */
|
|
150
|
+
export function batchUpdateTasks(
|
|
151
|
+
tasks: UpdateTaskParams[]
|
|
152
|
+
): Promise<void> {
|
|
153
|
+
return apiRequest<void>('task/batch', {
|
|
154
|
+
method: 'POST',
|
|
155
|
+
body: JSON.stringify({ update: tasks }),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 批量完成项目内任务 */
|
|
160
|
+
export async function completeTasksInProject(
|
|
161
|
+
projectId: string,
|
|
162
|
+
taskIds: string[]
|
|
163
|
+
): Promise<{ completed: string[]; failed: string[] }> {
|
|
164
|
+
const completed: string[] = [];
|
|
165
|
+
const failed: string[] = [];
|
|
166
|
+
for (const taskId of taskIds) {
|
|
167
|
+
try {
|
|
168
|
+
await completeTask(projectId, taskId);
|
|
169
|
+
completed.push(taskId);
|
|
170
|
+
} catch {
|
|
171
|
+
failed.push(taskId);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return { completed, failed };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── 查询 ──────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
/** 按 ID 查找任务(无需 projectId,同时搜索未完成和已完成任务) */
|
|
180
|
+
export async function getTaskById(taskId: string): Promise<Task> {
|
|
181
|
+
// 并行获取未完成任务和已完成任务,确保覆盖所有任务
|
|
182
|
+
// 注意:filterTasks API 有返回数量限制,改用 listUndoneTasksByDate 获取更完整的数据
|
|
183
|
+
const [undoneTasks, completedTasks] = await Promise.all([
|
|
184
|
+
listUndoneTasksByDate({}),
|
|
185
|
+
getCompletedTasks({}),
|
|
186
|
+
]);
|
|
187
|
+
const allTasks = [...undoneTasks, ...completedTasks];
|
|
188
|
+
const task = allTasks.find((t) => t.id === taskId);
|
|
189
|
+
if (!task) throw new Error(`任务 ${taskId} 不存在`);
|
|
190
|
+
return task;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 获取用户偏好 */
|
|
194
|
+
export function getUserPreference(): Promise<UserPreference> {
|
|
195
|
+
return apiRequest<UserPreference>('user/info');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** 按日期范围获取未完成任务 */
|
|
199
|
+
export function listUndoneTasksByDate(
|
|
200
|
+
params: UndoneTasksParams
|
|
201
|
+
): Promise<Task[]> {
|
|
202
|
+
const filterParams: FilterTasksParams = { status: [0] };
|
|
203
|
+
if (params.projectIds) filterParams.projectIds = params.projectIds;
|
|
204
|
+
if (params.startDate) filterParams.startDate = params.startDate;
|
|
205
|
+
if (params.endDate) filterParams.endDate = params.endDate;
|
|
206
|
+
return filterTasks(filterParams);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** 按预设查询获取未完成任务 */
|
|
210
|
+
export function listUndoneTasksByTimeQuery(
|
|
211
|
+
query: string
|
|
212
|
+
): Promise<Task[]> {
|
|
213
|
+
const now = new Date();
|
|
214
|
+
let startDate: string;
|
|
215
|
+
let endDate: string;
|
|
216
|
+
|
|
217
|
+
const toISO = (d: Date) => d.toISOString();
|
|
218
|
+
|
|
219
|
+
switch (query) {
|
|
220
|
+
case 'today': {
|
|
221
|
+
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
222
|
+
const end = new Date(start);
|
|
223
|
+
end.setDate(end.getDate() + 1);
|
|
224
|
+
startDate = toISO(start);
|
|
225
|
+
endDate = toISO(end);
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
case 'tomorrow': {
|
|
229
|
+
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
|
230
|
+
const end = new Date(start);
|
|
231
|
+
end.setDate(end.getDate() + 1);
|
|
232
|
+
startDate = toISO(start);
|
|
233
|
+
endDate = toISO(end);
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
case 'last24hour': {
|
|
237
|
+
const start = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
238
|
+
startDate = toISO(start);
|
|
239
|
+
endDate = toISO(now);
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
case 'next24hour': {
|
|
243
|
+
const end = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
|
244
|
+
startDate = toISO(now);
|
|
245
|
+
endDate = toISO(end);
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
case 'last7day': {
|
|
249
|
+
const start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
250
|
+
startDate = toISO(start);
|
|
251
|
+
endDate = toISO(now);
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
case 'next7day': {
|
|
255
|
+
const end = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
|
256
|
+
startDate = toISO(now);
|
|
257
|
+
endDate = toISO(end);
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
default:
|
|
261
|
+
throw new Error(
|
|
262
|
+
`不支持的查询预设: ${query},支持: today, tomorrow, last24hour, next24hour, last7day, next7day`
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return listUndoneTasksByDate({ startDate, endDate });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** 搜索任务(关键词匹配,同时搜索未完成和已完成任务) */
|
|
270
|
+
export async function searchTask(keyword: string): Promise<Task[]> {
|
|
271
|
+
// 并行获取未完成任务和已完成任务,确保覆盖所有任务
|
|
272
|
+
// 注意:filterTasks API 有返回数量限制,改用 listUndoneTasksByDate 获取更完整的数据
|
|
273
|
+
const [undoneTasks, completedTasks] = await Promise.all([
|
|
274
|
+
listUndoneTasksByDate({}),
|
|
275
|
+
getCompletedTasks({}),
|
|
276
|
+
]);
|
|
277
|
+
|
|
278
|
+
// 合并并去重(按 id)
|
|
279
|
+
const taskMap = new Map<string, Task>();
|
|
280
|
+
for (const t of [...undoneTasks, ...completedTasks]) {
|
|
281
|
+
taskMap.set(t.id, t);
|
|
282
|
+
}
|
|
283
|
+
const allTasks = Array.from(taskMap.values());
|
|
284
|
+
|
|
285
|
+
const lower = keyword.toLowerCase();
|
|
286
|
+
return allTasks.filter(
|
|
287
|
+
(t) =>
|
|
288
|
+
t.title.toLowerCase().includes(lower) ||
|
|
289
|
+
(t.content && t.content.toLowerCase().includes(lower))
|
|
290
|
+
);
|
|
291
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import type { Region, OAuthConfig } from '../types.js';
|
|
4
|
+
import { getOAuth, setOAuth, getToken, clearToken, isTokenValid, getRegion, setRegion } from '../utils/config.js';
|
|
5
|
+
import { getEndpoints } from '../utils/endpoints.js';
|
|
6
|
+
import { loginWithBrowser } from '../api/oauth.js';
|
|
7
|
+
import { apiRequest } from '../api/client.js';
|
|
8
|
+
|
|
9
|
+
const REGION_LABELS: Record<Region, string> = {
|
|
10
|
+
cn: '国内版(滴答清单)',
|
|
11
|
+
global: '国际版(TickTick)',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** 引导用户输入 OAuth 凭证 */
|
|
15
|
+
async function promptOAuthCredentials(developerUrl: string): Promise<OAuthConfig | undefined> {
|
|
16
|
+
p.log.info(`请访问 ${developerUrl} 获取凭证`);
|
|
17
|
+
p.log.info('Redirect URI 设置为: http://localhost:3000/callback\n');
|
|
18
|
+
|
|
19
|
+
const clientId = await p.text({
|
|
20
|
+
message: '请输入 Client ID',
|
|
21
|
+
validate: (v) => (!v ? 'Client ID 不能为空' : undefined),
|
|
22
|
+
});
|
|
23
|
+
if (p.isCancel(clientId)) return undefined;
|
|
24
|
+
|
|
25
|
+
const clientSecret = await p.text({
|
|
26
|
+
message: '请输入 Client Secret',
|
|
27
|
+
validate: (v) => (!v ? 'Client Secret 不能为空' : undefined),
|
|
28
|
+
});
|
|
29
|
+
if (p.isCancel(clientSecret)) return undefined;
|
|
30
|
+
|
|
31
|
+
const oauth: OAuthConfig = { clientId, clientSecret };
|
|
32
|
+
setOAuth(oauth);
|
|
33
|
+
p.log.success('凭证已保存');
|
|
34
|
+
return oauth;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** tt login */
|
|
38
|
+
export async function loginCommand(): Promise<void> {
|
|
39
|
+
const region = getRegion();
|
|
40
|
+
const endpoints = getEndpoints(region);
|
|
41
|
+
|
|
42
|
+
p.intro(pc.bgCyan(pc.black(` 滴答清单 CLI 登录 [${REGION_LABELS[region]}] `)));
|
|
43
|
+
|
|
44
|
+
const token = getToken();
|
|
45
|
+
if (token && isTokenValid()) {
|
|
46
|
+
p.outro(pc.green('已登录,无需重复登录。使用 tt logout 先登出。'));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let oauth = getOAuth();
|
|
51
|
+
|
|
52
|
+
// 检测凭证区域不匹配
|
|
53
|
+
if (oauth?.region && oauth.region !== region) {
|
|
54
|
+
p.log.warn(`当前区域为 ${REGION_LABELS[region]},但保存的凭证属于 ${REGION_LABELS[oauth.region]}`);
|
|
55
|
+
p.log.warn('凭证与区域不匹配会导致登录失败\n');
|
|
56
|
+
const reconfigure = await p.confirm({
|
|
57
|
+
message: `是否为 ${REGION_LABELS[region]} 重新配置凭证?`,
|
|
58
|
+
});
|
|
59
|
+
if (p.isCancel(reconfigure) || !reconfigure) {
|
|
60
|
+
p.outro('已取消');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
oauth = undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 旧凭证无区域标记,提示确认后再补充
|
|
67
|
+
if (oauth && !oauth.region) {
|
|
68
|
+
p.log.warn('保存的凭证未标记区域,可能与当前区域不匹配');
|
|
69
|
+
const confirmRegion = await p.confirm({
|
|
70
|
+
message: `这些凭证是否用于 ${REGION_LABELS[region]}?`,
|
|
71
|
+
});
|
|
72
|
+
if (p.isCancel(confirmRegion)) {
|
|
73
|
+
p.outro('已取消');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (confirmRegion) {
|
|
77
|
+
setOAuth(oauth);
|
|
78
|
+
p.log.success(`已将凭证标记为 ${REGION_LABELS[region]}`);
|
|
79
|
+
} else {
|
|
80
|
+
oauth = undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!oauth) {
|
|
85
|
+
oauth = await promptOAuthCredentials(endpoints.developerUrl);
|
|
86
|
+
if (!oauth) { p.outro('已取消'); return; }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const s = p.spinner();
|
|
90
|
+
s.start('正在打开浏览器进行授权...');
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
await loginWithBrowser(oauth);
|
|
94
|
+
s.stop('授权完成');
|
|
95
|
+
p.outro(pc.green('✔ 登录成功!'));
|
|
96
|
+
} catch (err) {
|
|
97
|
+
s.stop('登录失败');
|
|
98
|
+
const msg = (err as Error).message;
|
|
99
|
+
|
|
100
|
+
if (msg.includes('invalid_client') || msg.includes('TIMEOUT')) {
|
|
101
|
+
p.log.error(pc.red('登录失败:凭证无效或与区域不匹配'));
|
|
102
|
+
p.log.info(`当前区域:${REGION_LABELS[region]}`);
|
|
103
|
+
p.log.info(`凭证来源:${oauth.region ? REGION_LABELS[oauth.region] : '未知'}\n`);
|
|
104
|
+
|
|
105
|
+
const action = await p.select({
|
|
106
|
+
message: '请选择下一步操作',
|
|
107
|
+
options: [
|
|
108
|
+
{ value: 'switch', label: `切换到${region === 'cn' ? '国际版' : '国内版'}` },
|
|
109
|
+
{ value: 'reconfig', label: '重新输入当前区域的凭证' },
|
|
110
|
+
{ value: 'exit', label: '退出' },
|
|
111
|
+
],
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (p.isCancel(action) || action === 'exit') {
|
|
115
|
+
p.outro('已退出');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (action === 'switch') {
|
|
120
|
+
const newRegion: Region = region === 'cn' ? 'global' : 'cn';
|
|
121
|
+
setRegion(newRegion);
|
|
122
|
+
p.log.success(`已切换到 ${REGION_LABELS[newRegion]},请重新运行 tt login`);
|
|
123
|
+
p.outro('区域已切换');
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (action === 'reconfig') {
|
|
128
|
+
await promptOAuthCredentials(endpoints.developerUrl);
|
|
129
|
+
p.outro('凭证已更新,请重新运行 tt login');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
p.outro(pc.red(`✖ ${msg}`));
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** tt logout */
|
|
140
|
+
export async function logoutCommand(): Promise<void> {
|
|
141
|
+
p.intro(pc.bgCyan(pc.black(' 滴答清单 CLI 登出 ')));
|
|
142
|
+
clearToken();
|
|
143
|
+
p.outro(pc.green('✔ 已登出'));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** tt whoami */
|
|
147
|
+
export async function whoamiCommand(): Promise<void> {
|
|
148
|
+
const region = getRegion();
|
|
149
|
+
p.intro(pc.bgCyan(pc.black(' 滴答清单 CLI 状态 ')));
|
|
150
|
+
|
|
151
|
+
const token = getToken();
|
|
152
|
+
if (!token) {
|
|
153
|
+
p.outro(pc.yellow('未登录,请先运行 tt login'));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const s = p.spinner();
|
|
158
|
+
s.start('正在验证登录状态...');
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
await apiRequest<unknown[]>('project');
|
|
162
|
+
s.stop('验证完成');
|
|
163
|
+
|
|
164
|
+
const expiresIn = token.expiresAt - Date.now();
|
|
165
|
+
if (expiresIn <= 0) {
|
|
166
|
+
p.outro(pc.yellow('Token 已失效,请运行 tt login 重新登录'));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const hours = Math.floor(expiresIn / 3600000);
|
|
171
|
+
const minutes = Math.floor((expiresIn % 3600000) / 60000);
|
|
172
|
+
p.log.success(pc.green(`已登录 [${REGION_LABELS[region]}]`));
|
|
173
|
+
p.log.info(`Token 有效期: 剩余 ${hours} 小时 ${minutes} 分钟`);
|
|
174
|
+
p.outro('一切正常');
|
|
175
|
+
} catch {
|
|
176
|
+
s.stop('验证失败');
|
|
177
|
+
p.outro(pc.red('Token 已失效,请运行 tt login 重新登录'));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** tt config [--region cn|global] */
|
|
182
|
+
export async function configCommand(args?: { region?: Region }): Promise<void> {
|
|
183
|
+
if (args?.region) {
|
|
184
|
+
const oldRegion = getRegion();
|
|
185
|
+
setRegion(args.region);
|
|
186
|
+
clearToken();
|
|
187
|
+
p.intro(pc.bgCyan(pc.black(' 滴答清单 CLI 配置 ')));
|
|
188
|
+
p.log.success(`已切换到 ${REGION_LABELS[args.region]}`);
|
|
189
|
+
if (oldRegion !== args.region) {
|
|
190
|
+
p.log.warn('区域已变更,请重新运行 tt login');
|
|
191
|
+
}
|
|
192
|
+
p.outro('配置已更新');
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const region = getRegion();
|
|
197
|
+
p.intro(pc.bgCyan(pc.black(' 滴答清单 CLI 配置 ')));
|
|
198
|
+
p.log.info(`区域: ${REGION_LABELS[region]}`);
|
|
199
|
+
|
|
200
|
+
const oauth = getOAuth();
|
|
201
|
+
if (oauth) {
|
|
202
|
+
p.log.info(`Client ID: ${oauth.clientId}`);
|
|
203
|
+
p.log.info(`Client Secret: ${oauth.clientSecret.substring(0, 8)}${'*'.repeat(Math.max(0, oauth.clientSecret.length - 8))}`);
|
|
204
|
+
} else {
|
|
205
|
+
p.log.warn('尚未配置 OAuth 凭证');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const token = getToken();
|
|
209
|
+
if (token) {
|
|
210
|
+
p.log.info(`Token: ${token.accessToken.substring(0, 8)}...`);
|
|
211
|
+
p.log.info(`过期时间: ${new Date(token.expiresAt).toLocaleString('zh-CN')}`);
|
|
212
|
+
} else {
|
|
213
|
+
p.log.info('Token: 未登录');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
p.log.info('\n使用 tt config --region cn/global 切换区域');
|
|
217
|
+
p.outro('配置信息如上');
|
|
218
|
+
}
|