@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,1110 @@
1
+ # tt-cli 鉴权模块实现计划
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** 实现滴答清单 CLI 工具的 OAuth2 认证模块,支持登录/登出/状态查询。
6
+
7
+ **Architecture:** 本地启动临时 HTTP 服务器捕获 OAuth2 回调,自动打开浏览器完成授权,token 持久化到 `~/.tt-cli/config.json`,API 客户端自动刷新过期 token。
8
+
9
+ **Tech Stack:** TypeScript, cac, @clack/prompts, picocolors, tsup, vitest, open
10
+
11
+ ---
12
+
13
+ ## 文件结构
14
+
15
+ | 操作 | 文件 | 职责 |
16
+ |------|------|------|
17
+ | Create | `package.json` | 项目配置、依赖、bin |
18
+ | Create | `tsconfig.json` | TypeScript 配置 |
19
+ | Create | `tsup.config.ts` | 构建配置 |
20
+ | Create | `vitest.config.ts` | 测试配置 |
21
+ | Create | `src/types.ts` | 所有 TypeScript 类型 |
22
+ | Create | `src/utils/config.ts` | 配置文件读写(~/.tt-cli/config.json) |
23
+ | Create | `src/utils/server.ts` | 临时本地 OAuth2 回调服务器 |
24
+ | Create | `src/api/oauth.ts` | OAuth2 授权 URL 生成、code 换 token、token 刷新 |
25
+ | Create | `src/api/client.ts` | HTTP 客户端,自动附带 Bearer token 并处理刷新 |
26
+ | Create | `src/commands/auth.ts` | login/logout/whoami/config 命令实现 |
27
+ | Create | `src/index.ts` | cac 命令注册入口 |
28
+ | Create | `bin/cli.ts` | #!/usr/bin/env node 可执行入口 |
29
+ | Create | `tests/config.test.ts` | 配置模块测试 |
30
+ | Create | `tests/oauth.test.ts` | OAuth 工具函数测试 |
31
+
32
+ ---
33
+
34
+ ### Task 1: 项目脚手架
35
+
36
+ **Files:**
37
+ - Create: `package.json`
38
+ - Create: `tsconfig.json`
39
+ - Create: `tsup.config.ts`
40
+ - Create: `vitest.config.ts`
41
+ - Create: `bin/cli.ts`
42
+
43
+ - [ ] **Step 1: 初始化 package.json**
44
+
45
+ ```bash
46
+ cd /Users/jiashengwang/jacky-github/tt-cli
47
+ npm init -y
48
+ ```
49
+
50
+ 然后修改 `package.json` 为以下内容:
51
+
52
+ ```json
53
+ {
54
+ "name": "@wangjs-jacky/tt-cli",
55
+ "version": "0.1.0",
56
+ "description": "滴答清单(TickTick)命令行工具",
57
+ "type": "module",
58
+ "main": "dist/index.js",
59
+ "bin": {
60
+ "tt": "./dist/bin/cli.js"
61
+ },
62
+ "scripts": {
63
+ "build": "tsup",
64
+ "dev": "tsup --watch",
65
+ "test": "vitest run",
66
+ "test:watch": "vitest",
67
+ "prepublishOnly": "npm run build"
68
+ },
69
+ "keywords": ["ticktick", "dida", "cli", "todo", "task"],
70
+ "author": "wangjs-jacky",
71
+ "license": "MIT",
72
+ "repository": {
73
+ "type": "git",
74
+ "url": "git+https://github.com/wangjs-jacky/tt-cli.git"
75
+ }
76
+ }
77
+ ```
78
+
79
+ - [ ] **Step 2: 安装依赖**
80
+
81
+ ```bash
82
+ npm install cac @clack/prompts picocolors open
83
+ npm install -D typescript tsup vitest @types/node
84
+ ```
85
+
86
+ - [ ] **Step 3: 创建 tsconfig.json**
87
+
88
+ ```json
89
+ {
90
+ "compilerOptions": {
91
+ "target": "ES2022",
92
+ "module": "ESNext",
93
+ "moduleResolution": "bundler",
94
+ "lib": ["ES2022"],
95
+ "outDir": "dist",
96
+ "rootDir": ".",
97
+ "strict": true,
98
+ "esModuleInterop": true,
99
+ "skipLibCheck": true,
100
+ "forceConsistentCasingInFileNames": true,
101
+ "resolveJsonModule": true,
102
+ "declaration": true,
103
+ "declarationMap": true,
104
+ "sourceMap": true
105
+ },
106
+ "include": ["src/**/*", "bin/**/*", "tests/**/*"],
107
+ "exclude": ["node_modules", "dist"]
108
+ }
109
+ ```
110
+
111
+ - [ ] **Step 4: 创建 tsup.config.ts**
112
+
113
+ ```typescript
114
+ import { defineConfig } from 'tsup';
115
+
116
+ export default defineConfig({
117
+ entry: ['src/index.ts', 'bin/cli.ts'],
118
+ format: ['esm'],
119
+ target: 'node18',
120
+ clean: true,
121
+ splitting: false,
122
+ sourcemap: true,
123
+ dts: true,
124
+ });
125
+ ```
126
+
127
+ - [ ] **Step 5: 创建 vitest.config.ts**
128
+
129
+ ```typescript
130
+ import { defineConfig } from 'vitest/config';
131
+
132
+ export default defineConfig({
133
+ test: {
134
+ globals: true,
135
+ },
136
+ });
137
+ ```
138
+
139
+ - [ ] **Step 6: 创建 bin/cli.ts**
140
+
141
+ ```typescript
142
+ #!/usr/bin/env node
143
+ import '../dist/index.js';
144
+ ```
145
+
146
+ - [ ] **Step 7: 初始化 Git 并提交**
147
+
148
+ ```bash
149
+ cd /Users/jiashengwang/jacky-github/tt-cli
150
+ git init
151
+ ```
152
+
153
+ 创建 `.gitignore`:
154
+
155
+ ```
156
+ node_modules/
157
+ dist/
158
+ *.tsbuildinfo
159
+ ```
160
+
161
+ ```bash
162
+ git add .
163
+ git commit -m "chore: init tt-cli project scaffolding"
164
+ ```
165
+
166
+ ---
167
+
168
+ ### Task 2: TypeScript 类型定义
169
+
170
+ **Files:**
171
+ - Create: `src/types.ts`
172
+
173
+ - [ ] **Step 1: 创建 src/types.ts**
174
+
175
+ ```typescript
176
+ /** OAuth2 客户端凭证 */
177
+ export interface OAuthConfig {
178
+ clientId: string;
179
+ clientSecret: string;
180
+ }
181
+
182
+ /** 持久化的 Token 数据 */
183
+ export interface TokenData {
184
+ accessToken: string;
185
+ refreshToken: string;
186
+ expiresAt: number;
187
+ }
188
+
189
+ /** 配置文件结构 (~/.tt-cli/config.json) */
190
+ export interface AppConfig {
191
+ oauth?: OAuthConfig;
192
+ token?: TokenData;
193
+ }
194
+
195
+ /** TickTick OAuth2 token 响应 */
196
+ export interface TokenResponse {
197
+ access_token: string;
198
+ token_type: string;
199
+ expires_in: number;
200
+ refresh_token: string;
201
+ scope: string;
202
+ }
203
+ ```
204
+
205
+ - [ ] **Step 2: 验证类型编译通过**
206
+
207
+ ```bash
208
+ npx tsc --noEmit src/types.ts
209
+ ```
210
+
211
+ Expected: 无错误输出
212
+
213
+ - [ ] **Step 3: 提交**
214
+
215
+ ```bash
216
+ git add src/types.ts
217
+ git commit -m "feat: add TypeScript type definitions"
218
+ ```
219
+
220
+ ---
221
+
222
+ ### Task 3: 配置管理模块
223
+
224
+ **Files:**
225
+ - Create: `src/utils/config.ts`
226
+ - Create: `tests/config.test.ts`
227
+
228
+ - [ ] **Step 1: 编写配置模块测试 tests/config.test.ts**
229
+
230
+ ```typescript
231
+ import { describe, it, beforeEach, afterEach, expect } from 'vitest';
232
+ import fs from 'fs';
233
+ import path from 'path';
234
+ import os from 'os';
235
+
236
+ // 测试用临时目录,避免污染真实配置
237
+ const TEST_DIR = path.join(os.tmpdir(), 'tt-cli-test-' + process.pid);
238
+
239
+ // 动态导入,每个测试前重置模块
240
+ let config: typeof import('../src/utils/config.js');
241
+
242
+ beforeEach(async () => {
243
+ // 创建临时目录
244
+ fs.mkdirSync(TEST_DIR, { recursive: true });
245
+ // 用环境变量覆盖配置路径(模块内部读取此变量)
246
+ process.env.TT_CLI_CONFIG_DIR = TEST_DIR;
247
+ // 清除模块缓存以重新加载
248
+ const mod = await import('../src/utils/config.js?' + Date.now());
249
+ config = mod;
250
+ });
251
+
252
+ afterEach(() => {
253
+ fs.rmSync(TEST_DIR, { recursive: true, force: true });
254
+ delete process.env.TT_CLI_CONFIG_DIR;
255
+ });
256
+
257
+ describe('config module', () => {
258
+ it('无配置时应返回 undefined', () => {
259
+ expect(config.getOAuth()).toBeUndefined();
260
+ expect(config.getToken()).toBeUndefined();
261
+ });
262
+
263
+ it('应正确保存和读取 OAuth 凭证', () => {
264
+ const oauth = { clientId: 'test-id', clientSecret: 'test-secret' };
265
+ config.setOAuth(oauth);
266
+
267
+ const result = config.getOAuth();
268
+ expect(result).toEqual(oauth);
269
+ });
270
+
271
+ it('应正确保存和读取 Token', () => {
272
+ const token = {
273
+ accessToken: 'access-123',
274
+ refreshToken: 'refresh-456',
275
+ expiresAt: Date.now() + 3600000,
276
+ };
277
+ config.setToken(token);
278
+
279
+ const result = config.getToken();
280
+ expect(result).toEqual(token);
281
+ });
282
+
283
+ it('未过期 token 应判定为有效', () => {
284
+ config.setToken({
285
+ accessToken: 'access',
286
+ refreshToken: 'refresh',
287
+ expiresAt: Date.now() + 600000, // 10 分钟后过期
288
+ });
289
+ expect(config.isTokenValid()).toBe(true);
290
+ });
291
+
292
+ it('即将过期(5 分钟内)应判定为无效', () => {
293
+ config.setToken({
294
+ accessToken: 'access',
295
+ refreshToken: 'refresh',
296
+ expiresAt: Date.now() + 240000, // 4 分钟后过期
297
+ });
298
+ expect(config.isTokenValid()).toBe(false);
299
+ });
300
+
301
+ it('无 token 应判定为无效', () => {
302
+ expect(config.isTokenValid()).toBe(false);
303
+ });
304
+
305
+ it('clearToken 应只删除 token 保留 oauth', () => {
306
+ config.setOAuth({ clientId: 'id', clientSecret: 'secret' });
307
+ config.setToken({
308
+ accessToken: 'a',
309
+ refreshToken: 'r',
310
+ expiresAt: Date.now() + 3600000,
311
+ });
312
+
313
+ config.clearToken();
314
+
315
+ expect(config.getToken()).toBeUndefined();
316
+ expect(config.getOAuth()).toEqual({ clientId: 'id', clientSecret: 'secret' });
317
+ });
318
+
319
+ it('配置应写入 JSON 文件', () => {
320
+ config.setOAuth({ clientId: 'id', clientSecret: 'secret' });
321
+ const configPath = path.join(TEST_DIR, 'config.json');
322
+ const content = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
323
+ expect(content.oauth).toEqual({ clientId: 'id', clientSecret: 'secret' });
324
+ });
325
+ });
326
+ ```
327
+
328
+ - [ ] **Step 2: 运行测试,确认失败**
329
+
330
+ ```bash
331
+ npx vitest run tests/config.test.ts
332
+ ```
333
+
334
+ Expected: FAIL — 模块不存在
335
+
336
+ - [ ] **Step 3: 实现 src/utils/config.ts**
337
+
338
+ ```typescript
339
+ import fs from 'fs';
340
+ import path from 'path';
341
+ import os from 'os';
342
+ import type { AppConfig, OAuthConfig, TokenData } from '../types.js';
343
+
344
+ /** 配置目录,可通过环境变量覆盖(测试用) */
345
+ function getConfigDir(): string {
346
+ return process.env.TT_CLI_CONFIG_DIR ?? path.join(os.homedir(), '.tt-cli');
347
+ }
348
+
349
+ const CONFIG_FILE = 'config.json';
350
+
351
+ function getConfigPath(): string {
352
+ return path.join(getConfigDir(), CONFIG_FILE);
353
+ }
354
+
355
+ function ensureConfigDir(): void {
356
+ const dir = getConfigDir();
357
+ if (!fs.existsSync(dir)) {
358
+ fs.mkdirSync(dir, { recursive: true });
359
+ }
360
+ }
361
+
362
+ function readConfig(): AppConfig {
363
+ const configPath = getConfigPath();
364
+ if (!fs.existsSync(configPath)) return {};
365
+ return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
366
+ }
367
+
368
+ function writeConfig(config: AppConfig): void {
369
+ ensureConfigDir();
370
+ fs.writeFileSync(getConfigPath(), JSON.stringify(config, null, 2));
371
+ }
372
+
373
+ /** 读取 OAuth 凭证 */
374
+ export function getOAuth(): OAuthConfig | undefined {
375
+ return readConfig().oauth;
376
+ }
377
+
378
+ /** 保存 OAuth 凭证 */
379
+ export function setOAuth(oauth: OAuthConfig): void {
380
+ const config = readConfig();
381
+ config.oauth = oauth;
382
+ writeConfig(config);
383
+ }
384
+
385
+ /** 读取 Token */
386
+ export function getToken(): TokenData | undefined {
387
+ return readConfig().token;
388
+ }
389
+
390
+ /** 保存 Token */
391
+ export function setToken(token: TokenData): void {
392
+ const config = readConfig();
393
+ config.token = token;
394
+ writeConfig(config);
395
+ }
396
+
397
+ /** 清除 Token(保留 OAuth 凭证) */
398
+ export function clearToken(): void {
399
+ const config = readConfig();
400
+ delete config.token;
401
+ writeConfig(config);
402
+ }
403
+
404
+ /** Token 是否有效(未过期且剩余 > 5 分钟) */
405
+ export function isTokenValid(): boolean {
406
+ const token = getToken();
407
+ if (!token) return false;
408
+ return Date.now() < token.expiresAt - 5 * 60 * 1000;
409
+ }
410
+ ```
411
+
412
+ - [ ] **Step 4: 运行测试,确认通过**
413
+
414
+ ```bash
415
+ npx vitest run tests/config.test.ts
416
+ ```
417
+
418
+ Expected: 所有 7 个测试 PASS
419
+
420
+ - [ ] **Step 5: 提交**
421
+
422
+ ```bash
423
+ git add src/utils/config.ts tests/config.test.ts
424
+ git commit -m "feat: add config module with tests"
425
+ ```
426
+
427
+ ---
428
+
429
+ ### Task 4: OAuth2 回调服务器
430
+
431
+ **Files:**
432
+ - Create: `src/utils/server.ts`
433
+
434
+ - [ ] **Step 1: 实现 src/utils/server.ts**
435
+
436
+ ```typescript
437
+ import http from 'http';
438
+
439
+ interface CallbackResult {
440
+ code: string;
441
+ close: () => void;
442
+ }
443
+
444
+ /**
445
+ * 创建临时本地 HTTP 服务器,等待 OAuth2 回调
446
+ * 返回 Promise,在收到合法回调时 resolve
447
+ */
448
+ export function createCallbackServer(
449
+ expectedState: string,
450
+ port: number
451
+ ): Promise<CallbackResult> {
452
+ return new Promise((resolve, reject) => {
453
+ const server = http.createServer((req, res) => {
454
+ const url = new URL(req.url!, `http://localhost:${port}`);
455
+
456
+ if (url.pathname !== '/callback') {
457
+ res.writeHead(404);
458
+ res.end('Not found');
459
+ return;
460
+ }
461
+
462
+ const code = url.searchParams.get('code');
463
+ const state = url.searchParams.get('state');
464
+
465
+ if (state !== expectedState) {
466
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
467
+ res.end('<h1>授权失败:state 不匹配</h1>');
468
+ reject(new Error('CSRF state 不匹配'));
469
+ server.close();
470
+ return;
471
+ }
472
+
473
+ if (!code) {
474
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
475
+ res.end('<h1>授权失败:缺少 code 参数</h1>');
476
+ reject(new Error('缺少授权码'));
477
+ server.close();
478
+ return;
479
+ }
480
+
481
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
482
+ res.end('<html><body style="display:flex;justify-content:center;align-items:center;height:100vh;font-family:sans-serif"><h1>✅ 授权成功!请返回终端。</h1></body></html>');
483
+
484
+ resolve({
485
+ code,
486
+ close: () => server.close(),
487
+ });
488
+ });
489
+
490
+ server.on('error', (err: NodeJS.ErrnoException) => {
491
+ if (err.code === 'EADDRINUSE') {
492
+ reject(new Error(`端口 ${port} 已被占用,请关闭占用该端口的程序或等待重试`));
493
+ } else {
494
+ reject(err);
495
+ }
496
+ });
497
+
498
+ server.listen(port);
499
+ });
500
+ }
501
+ ```
502
+
503
+ - [ ] **Step 2: 验证编译通过**
504
+
505
+ ```bash
506
+ npx tsc --noEmit src/utils/server.ts
507
+ ```
508
+
509
+ Expected: 无错误
510
+
511
+ - [ ] **Step 3: 提交**
512
+
513
+ ```bash
514
+ git add src/utils/server.ts
515
+ git commit -m "feat: add OAuth2 callback server"
516
+ ```
517
+
518
+ ---
519
+
520
+ ### Task 5: OAuth2 流程模块
521
+
522
+ **Files:**
523
+ - Create: `src/api/oauth.ts`
524
+ - Create: `tests/oauth.test.ts`
525
+
526
+ - [ ] **Step 1: 编写 OAuth 工具函数测试 tests/oauth.test.ts**
527
+
528
+ ```typescript
529
+ import { describe, it, expect } from 'vitest';
530
+ import { generateState, buildAuthUrl } from '../src/api/oauth.js';
531
+ import type { OAuthConfig } from '../src/types.js';
532
+
533
+ describe('OAuth utilities', () => {
534
+ it('generateState 应返回 32 位十六进制字符串', () => {
535
+ const state = generateState();
536
+ expect(state).toMatch(/^[0-9a-f]{32}$/);
537
+ });
538
+
539
+ it('generateState 每次应返回不同的值', () => {
540
+ const a = generateState();
541
+ const b = generateState();
542
+ expect(a).not.toBe(b);
543
+ });
544
+
545
+ it('buildAuthUrl 应包含所有必要参数', () => {
546
+ const config: OAuthConfig = { clientId: 'my-client-id', clientSecret: 'my-secret' };
547
+ const state = 'test-state';
548
+ const port = 3000;
549
+
550
+ const url = buildAuthUrl(config, state, port);
551
+
552
+ expect(url).toContain('https://ticktick.com/oauth/authorize');
553
+ expect(url).toContain('client_id=my-client-id');
554
+ expect(url).toContain('response_type=code');
555
+ expect(url).toContain('redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback');
556
+ expect(url).toContain('scope=tasks%3Aread+tasks%3Awrite');
557
+ expect(url).toContain('state=test-state');
558
+ });
559
+
560
+ it('buildAuthUrl 不同端口应生成不同的 redirect_uri', () => {
561
+ const config: OAuthConfig = { clientId: 'id', clientSecret: 'secret' };
562
+ const url3000 = buildAuthUrl(config, 's', 3000);
563
+ const url8080 = buildAuthUrl(config, 's', 8080);
564
+
565
+ expect(url3000).toContain('localhost%3A3000');
566
+ expect(url8080).toContain('localhost%3A8080');
567
+ });
568
+ });
569
+ ```
570
+
571
+ - [ ] **Step 2: 运行测试,确认失败**
572
+
573
+ ```bash
574
+ npx vitest run tests/oauth.test.ts
575
+ ```
576
+
577
+ Expected: FAIL — 模块不存在
578
+
579
+ - [ ] **Step 3: 实现 src/api/oauth.ts**
580
+
581
+ ```typescript
582
+ import crypto from 'crypto';
583
+ import open from 'open';
584
+ import type { OAuthConfig, TokenData, TokenResponse } from '../types.js';
585
+ import { getOAuth, getToken, setToken } from '../utils/config.js';
586
+ import { createCallbackServer } from '../utils/server.js';
587
+
588
+ const AUTH_URL = 'https://ticktick.com/oauth/authorize';
589
+ const TOKEN_URL = 'https://ticktick.com/oauth/token';
590
+ const SCOPES = 'tasks:read tasks:write';
591
+ const DEFAULT_PORT = 3000;
592
+
593
+ /** 生成随机 state(防 CSRF) */
594
+ export function generateState(): string {
595
+ return crypto.randomBytes(16).toString('hex');
596
+ }
597
+
598
+ /** 构建授权 URL */
599
+ export function buildAuthUrl(config: OAuthConfig, state: string, port: number): string {
600
+ const redirectUri = `http://localhost:${port}/callback`;
601
+ const params = new URLSearchParams({
602
+ client_id: config.clientId,
603
+ response_type: 'code',
604
+ redirect_uri: redirectUri,
605
+ scope: SCOPES,
606
+ state,
607
+ });
608
+ return `${AUTH_URL}?${params.toString()}`;
609
+ }
610
+
611
+ /** 用授权码换取 Token */
612
+ export async function exchangeCode(
613
+ config: OAuthConfig,
614
+ code: string,
615
+ port: number
616
+ ): Promise<TokenData> {
617
+ const redirectUri = `http://localhost:${port}/callback`;
618
+ const credentials = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString('base64');
619
+
620
+ const response = await fetch(TOKEN_URL, {
621
+ method: 'POST',
622
+ headers: {
623
+ 'Content-Type': 'application/x-www-form-urlencoded',
624
+ 'Authorization': `Basic ${credentials}`,
625
+ },
626
+ body: new URLSearchParams({
627
+ code,
628
+ grant_type: 'authorization_code',
629
+ redirect_uri: redirectUri,
630
+ scope: SCOPES,
631
+ }).toString(),
632
+ });
633
+
634
+ if (!response.ok) {
635
+ const text = await response.text();
636
+ throw new Error(`Token 交换失败: ${response.status} ${text}`);
637
+ }
638
+
639
+ const data: TokenResponse = await response.json();
640
+ return {
641
+ accessToken: data.access_token,
642
+ refreshToken: data.refresh_token,
643
+ expiresAt: Date.now() + data.expires_in * 1000,
644
+ };
645
+ }
646
+
647
+ /** 刷新 Token */
648
+ export async function refreshAccessToken(): Promise<TokenData> {
649
+ const oauth = getOAuth();
650
+ const token = getToken();
651
+
652
+ if (!oauth || !token) {
653
+ throw new Error('未登录,请先运行 tt login');
654
+ }
655
+
656
+ const credentials = Buffer.from(`${oauth.clientId}:${oauth.clientSecret}`).toString('base64');
657
+
658
+ const response = await fetch(TOKEN_URL, {
659
+ method: 'POST',
660
+ headers: {
661
+ 'Content-Type': 'application/x-www-form-urlencoded',
662
+ 'Authorization': `Basic ${credentials}`,
663
+ },
664
+ body: new URLSearchParams({
665
+ grant_type: 'refresh_token',
666
+ refresh_token: token.refreshToken,
667
+ }).toString(),
668
+ });
669
+
670
+ if (!response.ok) {
671
+ const text = await response.text();
672
+ throw new Error(`Token 刷新失败: ${response.status} ${text}`);
673
+ }
674
+
675
+ const data: TokenResponse = await response.json();
676
+ const newToken: TokenData = {
677
+ accessToken: data.access_token,
678
+ refreshToken: data.refresh_token,
679
+ expiresAt: Date.now() + data.expires_in * 1000,
680
+ };
681
+ setToken(newToken);
682
+ return newToken;
683
+ }
684
+
685
+ /** 完整登录流程 */
686
+ export async function loginWithBrowser(config: OAuthConfig, port = DEFAULT_PORT): Promise<TokenData> {
687
+ const state = generateState();
688
+ const authUrl = buildAuthUrl(config, state, port);
689
+
690
+ // 先启动服务器
691
+ const codePromise = createCallbackServer(state, port);
692
+
693
+ // 再打开浏览器
694
+ await open(authUrl);
695
+
696
+ // 等待回调
697
+ const { code, close } = await codePromise;
698
+
699
+ // 交换 token
700
+ const token = await exchangeCode(config, code, port);
701
+ setToken(token);
702
+
703
+ // 关闭服务器
704
+ close();
705
+
706
+ return token;
707
+ }
708
+ ```
709
+
710
+ - [ ] **Step 4: 运行测试,确认通过**
711
+
712
+ ```bash
713
+ npx vitest run tests/oauth.test.ts
714
+ ```
715
+
716
+ Expected: 所有 4 个测试 PASS
717
+
718
+ - [ ] **Step 5: 提交**
719
+
720
+ ```bash
721
+ git add src/api/oauth.ts tests/oauth.test.ts
722
+ git commit -m "feat: add OAuth2 flow module with tests"
723
+ ```
724
+
725
+ ---
726
+
727
+ ### Task 6: API 客户端
728
+
729
+ **Files:**
730
+ - Create: `src/api/client.ts`
731
+
732
+ - [ ] **Step 1: 实现 src/api/client.ts**
733
+
734
+ ```typescript
735
+ import type { OAuthConfig, TokenData } from '../types.js';
736
+ import { getOAuth, getToken, isTokenValid } from '../utils/config.js';
737
+ import { refreshAccessToken } from './oauth.js';
738
+
739
+ const API_BASE = 'https://api.ticktick.com/open/v1/';
740
+
741
+ /** 获取有效的 access token(自动刷新) */
742
+ async function getValidToken(): Promise<string> {
743
+ const oauth = getOAuth();
744
+ const token = getToken();
745
+
746
+ if (!oauth || !token) {
747
+ throw new Error('未登录,请先运行 tt login');
748
+ }
749
+
750
+ if (isTokenValid()) {
751
+ return token.accessToken;
752
+ }
753
+
754
+ const newToken = await refreshAccessToken();
755
+ return newToken.accessToken;
756
+ }
757
+
758
+ /** 发送 TickTick Open API 请求 */
759
+ export async function apiRequest<T>(path: string, options?: RequestInit): Promise<T> {
760
+ const token = await getValidToken();
761
+
762
+ const response = await fetch(`${API_BASE}${path}`, {
763
+ ...options,
764
+ headers: {
765
+ 'Authorization': `Bearer ${token}`,
766
+ 'Content-Type': 'application/json',
767
+ ...options?.headers,
768
+ },
769
+ });
770
+
771
+ if (!response.ok) {
772
+ const text = await response.text();
773
+ throw new Error(`API 请求失败: ${response.status} ${text}`);
774
+ }
775
+
776
+ // 204 No Content
777
+ if (response.status === 204) {
778
+ return undefined as T;
779
+ }
780
+
781
+ return response.json();
782
+ }
783
+ ```
784
+
785
+ - [ ] **Step 2: 验证编译通过**
786
+
787
+ ```bash
788
+ npx tsc --noEmit
789
+ ```
790
+
791
+ Expected: 无错误
792
+
793
+ - [ ] **Step 3: 提交**
794
+
795
+ ```bash
796
+ git add src/api/client.ts
797
+ git commit -m "feat: add API client with auto token refresh"
798
+ ```
799
+
800
+ ---
801
+
802
+ ### Task 7: 命令实现
803
+
804
+ **Files:**
805
+ - Create: `src/commands/auth.ts`
806
+
807
+ - [ ] **Step 1: 实现 src/commands/auth.ts**
808
+
809
+ ```typescript
810
+ import * as p from '@clack/prompts';
811
+ import pc from 'picocolors';
812
+ import { getOAuth, setOAuth, getToken, clearToken, isTokenValid } from '../utils/config.js';
813
+ import { loginWithBrowser } from '../api/oauth.js';
814
+ import { apiRequest } from '../api/client.js';
815
+
816
+ /** tt login */
817
+ export async function loginCommand(): Promise<void> {
818
+ p.intro(pc.bgCyan(pc.black(' 滴答清单 CLI 登录 ')));
819
+
820
+ // 已登录则跳过
821
+ const token = getToken();
822
+ if (token && isTokenValid()) {
823
+ p.outro(pc.green('已登录,无需重复登录。使用 tt logout 先登出。'));
824
+ return;
825
+ }
826
+
827
+ // 获取或输入 OAuth 凭证
828
+ let oauth = getOAuth();
829
+ if (!oauth) {
830
+ p.log.info('首次使用需要注册 TickTick 开发者应用');
831
+ p.log.info('请访问 https://developer.ticktick.com/app 注册');
832
+ p.log.info('Redirect URI 设置为: http://localhost:3000/callback\n');
833
+
834
+ const clientId = await p.text({
835
+ message: '请输入 Client ID',
836
+ validate: (v) => (!v ? 'Client ID 不能为空' : undefined),
837
+ });
838
+ if (p.isCancel(clientId)) {
839
+ p.outro('已取消');
840
+ return;
841
+ }
842
+
843
+ const clientSecret = await p.text({
844
+ message: '请输入 Client Secret',
845
+ validate: (v) => (!v ? 'Client Secret 不能为空' : undefined),
846
+ });
847
+ if (p.isCancel(clientSecret)) {
848
+ p.outro('已取消');
849
+ return;
850
+ }
851
+
852
+ oauth = { clientId, clientSecret };
853
+ setOAuth(oauth);
854
+ p.log.success('OAuth 凭证已保存');
855
+ }
856
+
857
+ // 开始 OAuth 流程
858
+ const s = p.spinner();
859
+ s.start('正在打开浏览器进行授权...');
860
+
861
+ try {
862
+ await loginWithBrowser(oauth);
863
+ s.stop('授权完成');
864
+ p.outro(pc.green('✔ 登录成功!'));
865
+ } catch (err) {
866
+ s.stop('登录失败');
867
+ p.outro(pc.red(`✖ ${(err as Error).message}`));
868
+ process.exit(1);
869
+ }
870
+ }
871
+
872
+ /** tt logout */
873
+ export async function logoutCommand(): Promise<void> {
874
+ p.intro(pc.bgCyan(pc.black(' 滴答清单 CLI 登出 ')));
875
+ clearToken();
876
+ p.outro(pc.green('✔ 已登出'));
877
+ }
878
+
879
+ /** tt whoami */
880
+ export async function whoamiCommand(): Promise<void> {
881
+ p.intro(pc.bgCyan(pc.black(' 滴答清单 CLI 状态 ')));
882
+
883
+ const token = getToken();
884
+ if (!token) {
885
+ p.outro(pc.yellow('未登录,请先运行 tt login'));
886
+ return;
887
+ }
888
+
889
+ // 调用 API 验证 token 有效性
890
+ const s = p.spinner();
891
+ s.start('正在验证登录状态...');
892
+
893
+ try {
894
+ // 用获取项目列表来验证 token
895
+ await apiRequest<unknown[]>('project');
896
+ s.stop('验证完成');
897
+
898
+ const expiresIn = token.expiresAt - Date.now();
899
+ if (expiresIn <= 0) {
900
+ p.log.warn('Token 已过期');
901
+ p.outro(pc.yellow('Token 已失效,请运行 tt login 重新登录'));
902
+ return;
903
+ }
904
+
905
+ const hours = Math.floor(expiresIn / 3600000);
906
+ const minutes = Math.floor((expiresIn % 3600000) / 60000);
907
+ p.log.success(pc.green('已登录'));
908
+ p.log.info(`Token 有效期: 剩余 ${hours} 小时 ${minutes} 分钟`);
909
+ p.outro('一切正常');
910
+ } catch {
911
+ s.stop('验证失败');
912
+ p.outro(pc.red('Token 已失效,请运行 tt login 重新登录'));
913
+ }
914
+ }
915
+
916
+ /** tt config */
917
+ export async function configCommand(): Promise<void> {
918
+ p.intro(pc.bgCyan(pc.black(' 滴答清单 CLI 配置 ')));
919
+
920
+ const oauth = getOAuth();
921
+ if (oauth) {
922
+ p.log.info(`Client ID: ${oauth.clientId}`);
923
+ p.log.info(`Client Secret: ${oauth.clientSecret.substring(0, 8)}${'*'.repeat(Math.max(0, oauth.clientSecret.length - 8))}`);
924
+ } else {
925
+ p.log.warn('尚未配置 OAuth 凭证');
926
+ }
927
+
928
+ const token = getToken();
929
+ if (token) {
930
+ p.log.info(`Token: ${token.accessToken.substring(0, 8)}...`);
931
+ p.log.info(`过期时间: ${new Date(token.expiresAt).toLocaleString('zh-CN')}`);
932
+ } else {
933
+ p.log.info('Token: 未登录');
934
+ }
935
+
936
+ p.outro('配置信息如上');
937
+ }
938
+ ```
939
+
940
+ - [ ] **Step 2: 验证编译通过**
941
+
942
+ ```bash
943
+ npx tsc --noEmit
944
+ ```
945
+
946
+ Expected: 无错误
947
+
948
+ - [ ] **Step 3: 提交**
949
+
950
+ ```bash
951
+ git add src/commands/auth.ts
952
+ git commit -m "feat: add login/logout/whoami/config commands"
953
+ ```
954
+
955
+ ---
956
+
957
+ ### Task 8: CLI 入口
958
+
959
+ **Files:**
960
+ - Create: `src/index.ts`
961
+ - Modify: `bin/cli.ts`
962
+
963
+ - [ ] **Step 1: 实现 src/index.ts**
964
+
965
+ ```typescript
966
+ import { cac } from 'cac';
967
+ import { loginCommand, logoutCommand, whoamiCommand, configCommand } from './commands/auth.js';
968
+
969
+ const cli = cac('tt');
970
+
971
+ cli
972
+ .command('login', '登录滴答清单')
973
+ .action(loginCommand);
974
+
975
+ cli
976
+ .command('logout', '登出')
977
+ .action(logoutCommand);
978
+
979
+ cli
980
+ .command('whoami', '查看登录状态')
981
+ .action(whoamiCommand);
982
+
983
+ cli
984
+ .command('config', '查看配置')
985
+ .action(configCommand);
986
+
987
+ cli.help();
988
+ cli.parse();
989
+ ```
990
+
991
+ - [ ] **Step 2: 更新 bin/cli.ts(确保 shebang 正确)**
992
+
993
+ ```typescript
994
+ #!/usr/bin/env node
995
+ import '../dist/index.js';
996
+ ```
997
+
998
+ - [ ] **Step 3: 构建验证**
999
+
1000
+ ```bash
1001
+ npm run build
1002
+ ```
1003
+
1004
+ Expected: 成功生成 `dist/` 目录
1005
+
1006
+ - [ ] **Step 4: 测试 CLI 帮助信息**
1007
+
1008
+ ```bash
1009
+ node dist/bin/cli.js --help
1010
+ ```
1011
+
1012
+ Expected: 显示 tt 命令帮助信息,包含 login/logout/whoami/config
1013
+
1014
+ - [ ] **Step 5: 测试 tt whoami(未登录状态)**
1015
+
1016
+ ```bash
1017
+ node dist/bin/cli.js whoami
1018
+ ```
1019
+
1020
+ Expected: 显示"未登录,请先运行 tt login"
1021
+
1022
+ - [ ] **Step 6: 提交**
1023
+
1024
+ ```bash
1025
+ git add src/index.ts bin/cli.ts
1026
+ git commit -m "feat: add CLI entry point with cac"
1027
+ ```
1028
+
1029
+ ---
1030
+
1031
+ ### Task 9: 全量测试与清理
1032
+
1033
+ **Files:**
1034
+ - All files
1035
+
1036
+ - [ ] **Step 1: 运行所有测试**
1037
+
1038
+ ```bash
1039
+ npm test
1040
+ ```
1041
+
1042
+ Expected: 所有测试 PASS(config 7 个 + oauth 4 个 = 11 个)
1043
+
1044
+ - [ ] **Step 2: 运行完整构建**
1045
+
1046
+ ```bash
1047
+ npm run build
1048
+ ```
1049
+
1050
+ Expected: 构建成功,`dist/` 目录包含 `index.js` 和 `bin/cli.js`
1051
+
1052
+ - [ ] **Step 3: 创建 README.md**
1053
+
1054
+ ```markdown
1055
+ # tt-cli
1056
+
1057
+ 滴答清单(TickTick)命令行工具。
1058
+
1059
+ ## 安装
1060
+
1061
+ ```bash
1062
+ npm install -g @wangjs-jacky/tt-cli
1063
+ ```
1064
+
1065
+ ## 使用
1066
+
1067
+ ### 首次登录
1068
+
1069
+ ```bash
1070
+ tt login
1071
+ ```
1072
+
1073
+ 首次使用会提示输入 Client ID 和 Client Secret,需要先到 [TickTick 开发者平台](https://developer.ticktick.com/app) 注册应用。
1074
+
1075
+ 注册时 Redirect URI 设置为:`http://localhost:3000/callback`
1076
+
1077
+ ### 日常使用
1078
+
1079
+ ```bash
1080
+ tt whoami # 查看登录状态
1081
+ tt logout # 登出
1082
+ tt config # 查看配置
1083
+ ```
1084
+
1085
+ ## 开发
1086
+
1087
+ ```bash
1088
+ npm install # 安装依赖
1089
+ npm run build # 构建
1090
+ npm test # 运行测试
1091
+ npm run dev # 监听模式开发
1092
+ ```
1093
+ ```
1094
+
1095
+ - [ ] **Step 4: 最终提交**
1096
+
1097
+ ```bash
1098
+ git add .
1099
+ git commit -m "docs: add README"
1100
+ ```
1101
+
1102
+ ---
1103
+
1104
+ ## 自检清单
1105
+
1106
+ - [x] Spec 覆盖:设计文档中每个需求都有对应 Task
1107
+ - [x] 无占位符:所有步骤包含完整代码
1108
+ - [x] 类型一致:所有函数签名在定义和使用处一致
1109
+ - [x] TDD:config 和 oauth 模块有完整测试
1110
+ - [x] 构建路径:tsup 入口包含 index.ts 和 bin/cli.ts