@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
|
@@ -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
|