@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.
Files changed (40) hide show
  1. package/.github/workflows/npm-publish.yml +26 -0
  2. package/CLAUDE.md +34 -0
  3. package/README.md +62 -0
  4. package/README_CN.md +62 -0
  5. package/bin/cli.ts +2 -0
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.js +1490 -0
  8. package/dist/index.js.map +1 -0
  9. package/docs/oauth-credential-pre-validation.md +253 -0
  10. package/docs/reference/cli-usage-guide.md +587 -0
  11. package/docs/reference/dida365-open-api-zh.md +999 -0
  12. package/docs/reference/dida365-open-api.md +999 -0
  13. package/docs/reference/project-guide.md +63 -0
  14. package/docs/superpowers/plans/2026-04-03-tt-cli-auth.md +1110 -0
  15. package/docs/superpowers/specs/2026-04-03-tt-cli-design.md +142 -0
  16. package/package.json +45 -0
  17. package/skills/tt-cli-guide/SKILL.md +152 -0
  18. package/skills/tt-cli-guide/references/intent-mapping.md +169 -0
  19. package/src/api/client.ts +61 -0
  20. package/src/api/oauth.ts +146 -0
  21. package/src/api/resources.ts +291 -0
  22. package/src/commands/auth.ts +218 -0
  23. package/src/commands/project.ts +303 -0
  24. package/src/commands/task.ts +806 -0
  25. package/src/commands/user.ts +43 -0
  26. package/src/index.ts +46 -0
  27. package/src/types.ts +211 -0
  28. package/src/utils/config.ts +88 -0
  29. package/src/utils/endpoints.ts +22 -0
  30. package/src/utils/format.ts +71 -0
  31. package/src/utils/server.ts +81 -0
  32. package/tests/config.test.ts +87 -0
  33. package/tests/format.test.ts +56 -0
  34. package/tests/oauth.test.ts +42 -0
  35. package/tests/parity-fields.test.ts +89 -0
  36. package/tests/parity-map.ts +184 -0
  37. package/tests/parity.test.ts +101 -0
  38. package/tsconfig.json +22 -0
  39. package/tsup.config.ts +12 -0
  40. package/vitest.config.ts +7 -0
@@ -0,0 +1,43 @@
1
+ import * as p from '@clack/prompts';
2
+ import pc from 'picocolors';
3
+ import { getUserPreference } from '../api/resources.js';
4
+
5
+ async function userPrefCommand(): Promise<void> {
6
+ const s = p.spinner();
7
+ s.start('正在获取用户偏好...');
8
+
9
+ try {
10
+ const pref = await getUserPreference();
11
+ s.stop('获取成功');
12
+
13
+ console.log('');
14
+ if (pref.timeZone) console.log(` 时区: ${pref.timeZone}`);
15
+ if (pref.dateFormat !== undefined)
16
+ console.log(` 日期格式: ${pref.dateFormat}`);
17
+ if (pref.language) console.log(` 语言: ${pref.language}`);
18
+ if (pref.theme) console.log(` 主题: ${pref.theme}`);
19
+
20
+ // 如果所有字段都为空,显示原始数据
21
+ if (!pref.timeZone && pref.dateFormat === undefined && !pref.language && !pref.theme) {
22
+ console.log(pc.dim(' (未获取到偏好信息,API 可能不支持此端点)'));
23
+ }
24
+ console.log('');
25
+ p.outro('用户偏好如上');
26
+ } catch (err) {
27
+ s.stop('获取失败');
28
+ p.outro(pc.red(`获取用户偏好失败: ${(err as Error).message}`));
29
+ }
30
+ }
31
+
32
+ // ─── 注册命令 ────────────────────────────────────────
33
+
34
+ export function registerUserCommands(cli: {
35
+ command: (name: string, desc: string) => {
36
+ option: (flag: string, desc: string) => {
37
+ action: (fn: (...args: unknown[]) => Promise<void>) => unknown;
38
+ };
39
+ action: (fn: (...args: unknown[]) => Promise<void>) => unknown;
40
+ };
41
+ }): void {
42
+ cli.command('user-pref', '查看用户偏好设置').action(userPrefCommand);
43
+ }
package/src/index.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { cac } from 'cac';
2
+ import type { Region } from './types.js';
3
+ import { loginCommand, logoutCommand, whoamiCommand, configCommand } from './commands/auth.js';
4
+ import { registerProjectCommands } from './commands/project.js';
5
+ import { registerTaskCommands } from './commands/task.js';
6
+ import { registerUserCommands } from './commands/user.js';
7
+
8
+ // cac v7 不支持空格子命令(如 'task list'),需要将 argv 预处理为连字符格式
9
+ const SUBCOMMAND_GROUPS = ['task', 'project', 'user'];
10
+ const argv = process.argv.slice(2);
11
+ if (
12
+ argv.length >= 2 &&
13
+ SUBCOMMAND_GROUPS.includes(argv[0]) &&
14
+ !argv[1].startsWith('-')
15
+ ) {
16
+ argv.splice(0, 2, `${argv[0]}-${argv[1]}`);
17
+ }
18
+
19
+ const cli = cac('tt');
20
+
21
+ // 认证命令
22
+ cli.command('login', '登录滴答清单').action(loginCommand);
23
+ cli.command('logout', '登出').action(logoutCommand);
24
+ cli.command('whoami', '查看登录状态').action(whoamiCommand);
25
+ cli
26
+ .command('config', '查看/设置配置')
27
+ .option('--region <region>', '切换区域: cn (国内版) / global (国际版)')
28
+ .action(async (options: { region?: string }) => {
29
+ if (options.region && options.region !== 'cn' && options.region !== 'global') {
30
+ console.error(`无效的区域: ${options.region},请使用 cn 或 global`);
31
+ process.exit(1);
32
+ }
33
+ await configCommand({ region: options.region as Region | undefined });
34
+ });
35
+
36
+ // 项目命令
37
+ registerProjectCommands(cli);
38
+
39
+ // 任务命令
40
+ registerTaskCommands(cli);
41
+
42
+ // 用户命令
43
+ registerUserCommands(cli);
44
+
45
+ cli.help();
46
+ cli.parse(['', '', ...argv]);
package/src/types.ts ADDED
@@ -0,0 +1,211 @@
1
+ /** OAuth2 客户端凭证 */
2
+ export interface OAuthConfig {
3
+ clientId: string;
4
+ clientSecret: string;
5
+ /** 凭证所属区域 */
6
+ region?: Region;
7
+ }
8
+
9
+ /** 持久化的 Token 数据 */
10
+ export interface TokenData {
11
+ accessToken: string;
12
+ refreshToken: string;
13
+ expiresAt: number;
14
+ }
15
+
16
+ /** API 区域 */
17
+ export type Region = 'cn' | 'global';
18
+
19
+ /** 区域对应的接口地址 */
20
+ export interface RegionEndpoints {
21
+ authUrl: string;
22
+ tokenUrl: string;
23
+ apiBase: string;
24
+ developerUrl: string;
25
+ }
26
+
27
+ /** 配置文件结构 (~/.tt-cli/config.json) */
28
+ export interface AppConfig {
29
+ oauth?: OAuthConfig;
30
+ token?: TokenData;
31
+ region?: Region;
32
+ }
33
+
34
+ /** TickTick OAuth2 token 响应 */
35
+ export interface TokenResponse {
36
+ access_token: string;
37
+ token_type: string;
38
+ expires_in: number;
39
+ refresh_token: string;
40
+ scope: string;
41
+ }
42
+
43
+ // ─── API 数据类型 ────────────────────────────────────
44
+
45
+ /** 项目 */
46
+ export interface Project {
47
+ id: string;
48
+ name: string;
49
+ color: string;
50
+ sortOrder?: number;
51
+ closed?: boolean;
52
+ groupId?: string;
53
+ viewMode?: 'list' | 'kanban' | 'timeline';
54
+ permission?: 'read' | 'write' | 'comment';
55
+ kind?: 'TASK' | 'NOTE';
56
+ }
57
+
58
+ /** 看板列 */
59
+ export interface Column {
60
+ id: string;
61
+ projectId: string;
62
+ name: string;
63
+ sortOrder: number;
64
+ }
65
+
66
+ /** 项目及数据 */
67
+ export interface ProjectData {
68
+ project: Project;
69
+ tasks: Task[];
70
+ columns: Column[];
71
+ }
72
+
73
+ /** 清单子项 */
74
+ export interface ChecklistItem {
75
+ id?: string;
76
+ title: string;
77
+ status?: number;
78
+ completedTime?: string;
79
+ isAllDay?: boolean;
80
+ sortOrder?: number;
81
+ startDate?: string;
82
+ timeZone?: string;
83
+ }
84
+
85
+ /** 任务 */
86
+ export interface Task {
87
+ id: string;
88
+ projectId: string;
89
+ title: string;
90
+ isAllDay?: boolean;
91
+ completedTime?: string;
92
+ content?: string;
93
+ desc?: string;
94
+ dueDate?: string;
95
+ items?: ChecklistItem[];
96
+ priority?: number;
97
+ reminders?: string[];
98
+ repeatFlag?: string;
99
+ sortOrder?: number;
100
+ startDate?: string;
101
+ status?: number;
102
+ timeZone?: string;
103
+ kind?: 'TEXT' | 'NOTE' | 'CHECKLIST';
104
+ tags?: string[];
105
+ etag?: string;
106
+ /** 看板列 ID */
107
+ columnId?: string;
108
+ /** 父任务 ID */
109
+ parentId?: string;
110
+ /** 子任务 ID 列表 */
111
+ childIds?: string[];
112
+ /** 看板列名称 */
113
+ columnName?: string;
114
+ /** 任务指派人 */
115
+ assignor?: string;
116
+ }
117
+
118
+ // ─── API 请求参数类型 ────────────────────────────────
119
+
120
+ /** 创建项目参数 */
121
+ export interface CreateProjectParams {
122
+ name: string;
123
+ color?: string;
124
+ sortOrder?: number;
125
+ viewMode?: 'list' | 'kanban' | 'timeline';
126
+ kind?: 'TASK' | 'NOTE';
127
+ }
128
+
129
+ /** 更新项目参数 */
130
+ export interface UpdateProjectParams {
131
+ name?: string;
132
+ color?: string;
133
+ sortOrder?: number;
134
+ viewMode?: 'list' | 'kanban' | 'timeline';
135
+ kind?: 'TASK' | 'NOTE';
136
+ }
137
+
138
+ /** 创建任务参数 */
139
+ export interface CreateTaskParams {
140
+ title: string;
141
+ projectId: string;
142
+ content?: string;
143
+ desc?: string;
144
+ isAllDay?: boolean;
145
+ startDate?: string;
146
+ dueDate?: string;
147
+ timeZone?: string;
148
+ reminders?: string[];
149
+ repeatFlag?: string;
150
+ priority?: number;
151
+ sortOrder?: number;
152
+ items?: ChecklistItem[];
153
+ }
154
+
155
+ /** 更新任务参数 */
156
+ export interface UpdateTaskParams {
157
+ id: string;
158
+ projectId: string;
159
+ title?: string;
160
+ content?: string;
161
+ desc?: string;
162
+ isAllDay?: boolean;
163
+ startDate?: string;
164
+ dueDate?: string;
165
+ timeZone?: string;
166
+ reminders?: string[];
167
+ repeatFlag?: string;
168
+ priority?: number;
169
+ sortOrder?: number;
170
+ items?: ChecklistItem[];
171
+ }
172
+
173
+ /** 移动任务参数 */
174
+ export interface MoveTaskParams {
175
+ fromProjectId: string;
176
+ toProjectId: string;
177
+ taskId: string;
178
+ }
179
+
180
+ /** 已完成任务查询参数 */
181
+ export interface CompletedTasksParams {
182
+ projectIds?: string[];
183
+ startDate?: string;
184
+ endDate?: string;
185
+ }
186
+
187
+ /** 筛选任务参数 */
188
+ export interface FilterTasksParams {
189
+ projectIds?: string[];
190
+ startDate?: string;
191
+ endDate?: string;
192
+ priority?: number[];
193
+ tag?: string[];
194
+ status?: number[];
195
+ }
196
+
197
+ /** 批量任务操作参数 */
198
+ export interface BatchTasksParams {
199
+ add?: CreateTaskParams[];
200
+ update?: UpdateTaskParams[];
201
+ }
202
+
203
+ /** 用户偏好 */
204
+ export interface UserPreference {
205
+ timeZone?: string;
206
+ dateFormat?: string;
207
+ timeFormat?: string;
208
+ weekStartDay?: string;
209
+ language?: string;
210
+ [key: string]: unknown;
211
+ }
@@ -0,0 +1,88 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import type { AppConfig, OAuthConfig, Region, TokenData } from '../types.js';
5
+
6
+ /** 配置目录,可通过环境变量覆盖(测试用) */
7
+ function getConfigDir(): string {
8
+ return process.env.TT_CLI_CONFIG_DIR ?? path.join(os.homedir(), '.tt-cli');
9
+ }
10
+
11
+ const CONFIG_FILE = 'config.json';
12
+
13
+ function getConfigPath(): string {
14
+ return path.join(getConfigDir(), CONFIG_FILE);
15
+ }
16
+
17
+ function ensureConfigDir(): void {
18
+ const dir = getConfigDir();
19
+ if (!fs.existsSync(dir)) {
20
+ fs.mkdirSync(dir, { recursive: true });
21
+ }
22
+ }
23
+
24
+ function readConfig(): AppConfig {
25
+ const configPath = getConfigPath();
26
+ if (!fs.existsSync(configPath)) return {};
27
+ return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
28
+ }
29
+
30
+ function writeConfig(config: AppConfig): void {
31
+ ensureConfigDir();
32
+ fs.writeFileSync(getConfigPath(), JSON.stringify(config, null, 2));
33
+ }
34
+
35
+ /** 读取区域设置,默认国内版 */
36
+ export function getRegion(): Region {
37
+ return readConfig().region ?? 'cn';
38
+ }
39
+
40
+ /** 保存区域设置 */
41
+ export function setRegion(region: Region): void {
42
+ const config = readConfig();
43
+ config.region = region;
44
+ writeConfig(config);
45
+ }
46
+
47
+ /** 读取 OAuth 凭证 */
48
+ export function getOAuth(): OAuthConfig | undefined {
49
+ return readConfig().oauth;
50
+ }
51
+
52
+ /** 保存 OAuth 凭证(同时记录当前区域) */
53
+ export function setOAuth(oauth: OAuthConfig): void {
54
+ const config = readConfig();
55
+ config.oauth = { ...oauth, region: getRegion() };
56
+ writeConfig(config);
57
+ }
58
+
59
+ /** 获取凭证所属区域 */
60
+ export function getOAuthRegion(): Region | undefined {
61
+ return readConfig().oauth?.region;
62
+ }
63
+
64
+ /** 读取 Token */
65
+ export function getToken(): TokenData | undefined {
66
+ return readConfig().token;
67
+ }
68
+
69
+ /** 保存 Token */
70
+ export function setToken(token: TokenData): void {
71
+ const config = readConfig();
72
+ config.token = token;
73
+ writeConfig(config);
74
+ }
75
+
76
+ /** 清除 Token(保留 OAuth 凭证) */
77
+ export function clearToken(): void {
78
+ const config = readConfig();
79
+ delete config.token;
80
+ writeConfig(config);
81
+ }
82
+
83
+ /** Token 是否有效(未过期且剩余 > 5 分钟) */
84
+ export function isTokenValid(): boolean {
85
+ const token = getToken();
86
+ if (!token) return false;
87
+ return Date.now() < token.expiresAt - 5 * 60 * 1000;
88
+ }
@@ -0,0 +1,22 @@
1
+ import type { Region, RegionEndpoints } from '../types.js';
2
+
3
+ /** 区域接口地址映射 */
4
+ const ENDPOINTS: Record<Region, RegionEndpoints> = {
5
+ cn: {
6
+ authUrl: 'https://dida365.com/oauth/authorize',
7
+ tokenUrl: 'https://dida365.com/oauth/token',
8
+ apiBase: 'https://api.dida365.com/open/v1/',
9
+ developerUrl: 'https://developer.dida365.com/app',
10
+ },
11
+ global: {
12
+ authUrl: 'https://ticktick.com/oauth/authorize',
13
+ tokenUrl: 'https://ticktick.com/oauth/token',
14
+ apiBase: 'https://api.ticktick.com/open/v1/',
15
+ developerUrl: 'https://developer.ticktick.com/app',
16
+ },
17
+ };
18
+
19
+ /** 获取指定区域的接口地址 */
20
+ export function getEndpoints(region: Region): RegionEndpoints {
21
+ return ENDPOINTS[region];
22
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * 任务时间格式化工具
3
+ */
4
+
5
+ /**
6
+ * 规范化日期字符串为 TickTick API 要求的格式
7
+ * TickTick API 要求: "2026-04-04T19:00:00.000+0800"(有毫秒,时区无冒号)
8
+ *
9
+ * 支持输入格式:
10
+ * - "2026-04-04T19:00:00+08:00" → "2026-04-04T19:00:00.000+0800"
11
+ * - "2026-04-04T19:00:00Z" → "2026-04-04T19:00:00.000+0000"
12
+ * - "2026-04-04T19:00:00.123+0800" → 保持不变
13
+ */
14
+ export function normalizeTickTickDate(dateStr: string): string {
15
+ let result = dateStr;
16
+ // 补毫秒: "T19:00:00+" → "T19:00:00.000+"
17
+ if (!/T\d{2}:\d{2}:\d{2}\.\d{3}/.test(result)) {
18
+ result = result.replace(/(T\d{2}:\d{2}:\d{2})([+\-Z])/, '$1.000$2');
19
+ }
20
+ // 去时区冒号: "+08:00" → "+0800"
21
+ result = result.replace(/([+\-])(\d{2}):(\d{2})$/, '$1$2$3');
22
+ // "Z" → "+0000"
23
+ if (result.endsWith('Z')) {
24
+ result = result.slice(0, -1) + '+0000';
25
+ }
26
+ return result;
27
+ }
28
+
29
+ /** 从 TickTick ISO 8601 日期字符串提取本地时间的 HH:mm */
30
+ function extractHM(dateStr: string): string | null {
31
+ // TickTick 返回格式如 "2026-04-04T14:15:00+0000" 或 "2026-04-04T14:15:00+08:00"
32
+ // API 返回 UTC 时间,需转换为本地时间显示
33
+ try {
34
+ const d = new Date(dateStr);
35
+ if (isNaN(d.getTime())) return null;
36
+ const hours = d.getHours().toString().padStart(2, '0');
37
+ const minutes = d.getMinutes().toString().padStart(2, '0');
38
+ return `${hours}:${minutes}`;
39
+ } catch {
40
+ // 回退:直接从字符串提取 HH:mm
41
+ const match = dateStr.match(/T(\d{2}):(\d{2})/);
42
+ if (!match) return null;
43
+ return `${match[1]}:${match[2]}`;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * 格式化任务时间范围,用于表格显示
49
+ * - 全天任务显示 "全天"
50
+ * - 有起止时间显示 "HH:mm-HH:mm"
51
+ * - 仅有开始时间显示 "HH:mm"
52
+ * - 无时间显示空字符串
53
+ */
54
+ export function formatTaskTime(task: {
55
+ startDate?: string;
56
+ dueDate?: string;
57
+ isAllDay?: boolean;
58
+ }): string {
59
+ if (task.isAllDay && task.startDate) return '全天';
60
+ if (!task.startDate) return '';
61
+
62
+ const start = extractHM(task.startDate);
63
+ if (!start) return '';
64
+
65
+ if (task.dueDate) {
66
+ const end = extractHM(task.dueDate);
67
+ if (end && end !== start) return `${start}-${end}`;
68
+ }
69
+
70
+ return start;
71
+ }
@@ -0,0 +1,81 @@
1
+ import http from 'http';
2
+
3
+ interface CallbackResult {
4
+ code: string;
5
+ close: () => void;
6
+ }
7
+
8
+ /**
9
+ * 创建临时本地 HTTP 服务器,等待 OAuth2 回调
10
+ * 返回 Promise,在收到合法回调时 resolve,超时 2 分钟后 reject
11
+ */
12
+ export function createCallbackServer(
13
+ expectedState: string,
14
+ port: number
15
+ ): Promise<CallbackResult> {
16
+ return new Promise((resolve, reject) => {
17
+ let settled = false;
18
+
19
+ const server = http.createServer((req, res) => {
20
+ const url = new URL(req.url!, `http://localhost:${port}`);
21
+
22
+ if (url.pathname !== '/callback') {
23
+ res.writeHead(404);
24
+ res.end('Not found');
25
+ return;
26
+ }
27
+
28
+ const code = url.searchParams.get('code');
29
+ const state = url.searchParams.get('state');
30
+
31
+ if (state !== expectedState) {
32
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
33
+ res.end('<h1>授权失败:state 不匹配</h1>');
34
+ if (!settled) { settled = true; reject(new Error('CSRF state 不匹配')); }
35
+ server.close();
36
+ return;
37
+ }
38
+
39
+ if (!code) {
40
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
41
+ res.end('<h1>授权失败:缺少 code 参数</h1>');
42
+ if (!settled) { settled = true; reject(new Error('缺少授权码')); }
43
+ server.close();
44
+ return;
45
+ }
46
+
47
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
48
+ res.end('<html><body style="display:flex;justify-content:center;align-items:center;height:100vh;font-family:sans-serif"><h1>✅ 授权成功!请返回终端。</h1></body></html>');
49
+
50
+ if (!settled) {
51
+ settled = true;
52
+ resolve({
53
+ code,
54
+ close: () => server.close(),
55
+ });
56
+ }
57
+ });
58
+
59
+ server.on('error', (err: NodeJS.ErrnoException) => {
60
+ if (!settled) {
61
+ settled = true;
62
+ if (err.code === 'EADDRINUSE') {
63
+ reject(new Error(`端口 ${port} 已被占用,请关闭占用该端口的程序或等待重试`));
64
+ } else {
65
+ reject(err);
66
+ }
67
+ }
68
+ });
69
+
70
+ server.listen(port);
71
+
72
+ // 2 分钟超时
73
+ setTimeout(() => {
74
+ if (!settled) {
75
+ settled = true;
76
+ server.close();
77
+ reject(new Error('TIMEOUT'));
78
+ }
79
+ }, 120_000);
80
+ });
81
+ }
@@ -0,0 +1,87 @@
1
+ import { describe, it, beforeEach, afterEach, expect } from 'vitest';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+
6
+ // 测试用临时目录,避免污染真实配置
7
+ const TEST_DIR = path.join(os.tmpdir(), 'tt-cli-test-' + process.pid);
8
+
9
+ let config: typeof import('../src/utils/config.js');
10
+
11
+ beforeEach(async () => {
12
+ fs.mkdirSync(TEST_DIR, { recursive: true });
13
+ process.env.TT_CLI_CONFIG_DIR = TEST_DIR;
14
+ const mod = await import('../src/utils/config.js?' + Date.now());
15
+ config = mod;
16
+ });
17
+
18
+ afterEach(() => {
19
+ fs.rmSync(TEST_DIR, { recursive: true, force: true });
20
+ delete process.env.TT_CLI_CONFIG_DIR;
21
+ });
22
+
23
+ describe('config module', () => {
24
+ it('无配置时应返回 undefined', () => {
25
+ expect(config.getOAuth()).toBeUndefined();
26
+ expect(config.getToken()).toBeUndefined();
27
+ });
28
+
29
+ it('应正确保存和读取 OAuth 凭证', () => {
30
+ const oauth = { clientId: 'test-id', clientSecret: 'test-secret' };
31
+ config.setOAuth(oauth);
32
+ const result = config.getOAuth();
33
+ expect(result).toMatchObject(oauth);
34
+ });
35
+
36
+ it('应正确保存和读取 Token', () => {
37
+ const token = {
38
+ accessToken: 'access-123',
39
+ refreshToken: 'refresh-456',
40
+ expiresAt: Date.now() + 3600000,
41
+ };
42
+ config.setToken(token);
43
+ const result = config.getToken();
44
+ expect(result).toEqual(token);
45
+ });
46
+
47
+ it('未过期 token 应判定为有效', () => {
48
+ config.setToken({
49
+ accessToken: 'access',
50
+ refreshToken: 'refresh',
51
+ expiresAt: Date.now() + 600000,
52
+ });
53
+ expect(config.isTokenValid()).toBe(true);
54
+ });
55
+
56
+ it('即将过期(5 分钟内)应判定为无效', () => {
57
+ config.setToken({
58
+ accessToken: 'access',
59
+ refreshToken: 'refresh',
60
+ expiresAt: Date.now() + 240000,
61
+ });
62
+ expect(config.isTokenValid()).toBe(false);
63
+ });
64
+
65
+ it('无 token 应判定为无效', () => {
66
+ expect(config.isTokenValid()).toBe(false);
67
+ });
68
+
69
+ it('clearToken 应只删除 token 保留 oauth', () => {
70
+ config.setOAuth({ clientId: 'id', clientSecret: 'secret' });
71
+ config.setToken({
72
+ accessToken: 'a',
73
+ refreshToken: 'r',
74
+ expiresAt: Date.now() + 3600000,
75
+ });
76
+ config.clearToken();
77
+ expect(config.getToken()).toBeUndefined();
78
+ expect(config.getOAuth()).toMatchObject({ clientId: 'id', clientSecret: 'secret' });
79
+ });
80
+
81
+ it('配置应写入 JSON 文件', () => {
82
+ config.setOAuth({ clientId: 'id', clientSecret: 'secret' });
83
+ const configPath = path.join(TEST_DIR, 'config.json');
84
+ const content = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
85
+ expect(content.oauth).toMatchObject({ clientId: 'id', clientSecret: 'secret' });
86
+ });
87
+ });