ticktick-cli 0.1.1 → 1.0.1

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/README.md CHANGED
@@ -1,11 +1,13 @@
1
- # TickTick CLI
1
+ # TickTick CLI v1
2
2
 
3
- A simple TypeScript CLI wrapper for the TickTick Open API documented at:
3
+ A TypeScript CLI wrapper for the TickTick Open API documented at:
4
4
 
5
5
  - https://developer.ticktick.com/
6
6
  - https://developer.ticktick.com/docs#/openapi
7
7
 
8
- This wrapper covers the documented OAuth flow plus every documented task and project endpoint.
8
+ `v1.0.0` covers the documented OAuth flow plus every documented task and project endpoint.
9
+
10
+ The CLI is available as both `ticktick` and the short alias `tt`.
9
11
 
10
12
  ## What it covers
11
13
 
@@ -19,6 +21,10 @@ This wrapper covers the documented OAuth flow plus every documented task and pro
19
21
 
20
22
  ## Install
21
23
 
24
+ Requires Node.js `18+`.
25
+
26
+ Install from the local repo:
27
+
22
28
  ```bash
23
29
  npm install
24
30
  npm run build
@@ -27,7 +33,7 @@ npm run build
27
33
  Run locally with:
28
34
 
29
35
  ```bash
30
- node dist/cli.js --help
36
+ node dist/bin.js --help
31
37
  ```
32
38
 
33
39
  Or install the built CLI globally from this directory:
@@ -35,8 +41,19 @@ Or install the built CLI globally from this directory:
35
41
  ```bash
36
42
  npm install -g .
37
43
  ticktick --help
44
+ tt --help
45
+ ```
46
+
47
+ Once published to npm, install it with:
48
+
49
+ ```bash
50
+ npm install -g ticktick-cli
51
+ ticktick --help
52
+ tt --help
38
53
  ```
39
54
 
55
+ All command examples below use `ticktick`, but `tt` works the same way.
56
+
40
57
  ## Configure
41
58
 
42
59
  The CLI reads config in this order:
@@ -60,6 +77,12 @@ TICKTICK_AUTH_BASE_URL=https://ticktick.com
60
77
  TICKTICK_CONFIG_FILE=/custom/path/config.json
61
78
  ```
62
79
 
80
+ Default local redirect URI:
81
+
82
+ ```bash
83
+ http://127.0.0.1:18463/callback
84
+ ```
85
+
63
86
  You can also persist config values:
64
87
 
65
88
  ```bash
@@ -180,6 +203,22 @@ Send a raw request to a full URL without bearer auth:
180
203
  ticktick request POST https://httpbin.org/post --no-auth --json '{"hello":"world"}'
181
204
  ```
182
205
 
206
+ ## Development
207
+
208
+ Run the normal test suite:
209
+
210
+ ```bash
211
+ npm test
212
+ ```
213
+
214
+ Run the enforced coverage check:
215
+
216
+ ```bash
217
+ npm run coverage
218
+ ```
219
+
220
+ `npm run coverage` currently enforces `100%` line, branch, and function coverage on the published runtime files.
221
+
183
222
  ## Notes
184
223
 
185
224
  - The docs currently show `api.ticktick.com` for most endpoints, but `api.dida365.com` in the examples for `task/move`, `task/completed`, and `task/filter`. This CLI defaults to the selected service profile and lets you override base URLs explicitly if your account needs something different.
package/dist/auth.js CHANGED
@@ -32,7 +32,16 @@ export async function exchangeAuthorizationCode(config, code, fetchImpl = fetch)
32
32
  body: params,
33
33
  });
34
34
  const raw = await response.text();
35
- const parsed = raw.length > 0 ? JSON.parse(raw) : {};
35
+ let parsed = {};
36
+ if (raw.length > 0) {
37
+ try {
38
+ parsed = JSON.parse(raw);
39
+ }
40
+ catch (error) {
41
+ const message = error instanceof Error ? error.message : String(error);
42
+ throw new Error(`Token exchange returned invalid JSON: ${message}`);
43
+ }
44
+ }
36
45
  if (!response.ok) {
37
46
  throw new Error(`Token exchange failed with status ${response.status}: ${JSON.stringify(parsed)}`);
38
47
  }
@@ -46,18 +55,24 @@ export function isLoopbackRedirect(redirectUri) {
46
55
  return (parsed.protocol === "http:" &&
47
56
  ["127.0.0.1", "localhost"].includes(parsed.hostname));
48
57
  }
58
+ export function getOAuthCallbackBinding(redirectUri) {
59
+ const redirect = new URL(redirectUri);
60
+ return {
61
+ hostname: redirect.hostname === "localhost" ? "127.0.0.1" : redirect.hostname,
62
+ port: Number.parseInt(redirect.port || "80", 10),
63
+ callbackPath: redirect.pathname,
64
+ };
65
+ }
49
66
  export async function waitForOAuthCode(redirectUri, expectedState, timeoutMs = 120_000) {
50
67
  const redirect = new URL(redirectUri);
51
- const hostname = redirect.hostname === "localhost" ? "127.0.0.1" : redirect.hostname;
52
- const port = Number.parseInt(redirect.port || "80", 10);
53
- const callbackPath = redirect.pathname || "/";
68
+ const { hostname, port, callbackPath } = getOAuthCallbackBinding(redirectUri);
54
69
  return new Promise((resolve, reject) => {
55
70
  const timeout = setTimeout(() => {
56
71
  server.close();
57
72
  reject(new Error("Timed out waiting for the OAuth callback."));
58
73
  }, timeoutMs);
59
74
  const server = createServer((request, response) => {
60
- const requestUrl = new URL(request.url ?? "/", redirect.origin);
75
+ const requestUrl = new URL(request.url, redirect.origin);
61
76
  if (requestUrl.pathname !== callbackPath) {
62
77
  response.statusCode = 404;
63
78
  response.end("Not Found");
@@ -91,20 +106,29 @@ export async function waitForOAuthCode(redirectUri, expectedState, timeoutMs = 1
91
106
  server.listen(port, hostname);
92
107
  });
93
108
  }
94
- export async function openBrowser(url) {
109
+ export async function openBrowser(url, options) {
110
+ return openBrowserWith(url, options);
111
+ }
112
+ export async function openBrowserWith(url, options = {}) {
113
+ const platform = options.platform ?? process.platform;
114
+ const run = options.execFile ??
115
+ (async (file, args) => {
116
+ await execFileAsync(file, args);
117
+ });
118
+ const stderr = options.stderr ?? ((text) => process.stderr.write(text));
95
119
  try {
96
- if (process.platform === "win32") {
97
- await execFileAsync("cmd", ["/c", "start", "", url]);
120
+ if (platform === "win32") {
121
+ await run("cmd", ["/c", "start", "", url]);
98
122
  return;
99
123
  }
100
- if (process.platform === "darwin") {
101
- await execFileAsync("open", [url]);
124
+ if (platform === "darwin") {
125
+ await run("open", [url]);
102
126
  return;
103
127
  }
104
- await execFileAsync("xdg-open", [url]);
128
+ await run("xdg-open", [url]);
105
129
  }
106
130
  catch (error) {
107
131
  const message = error instanceof Error ? error.message : String(error);
108
- process.stderr.write(`Could not open a browser automatically: ${message}\n`);
132
+ stderr(`Could not open a browser automatically: ${message}\n`);
109
133
  }
110
134
  }
package/dist/bin.js ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "./cli.js";
3
+ await main();
package/dist/cli.js CHANGED
@@ -1,4 +1,3 @@
1
- #!/usr/bin/env node
2
1
  import { Command } from "commander";
3
2
  import { buildAuthorizationUrl, exchangeAuthorizationCode, isLoopbackRedirect, openBrowser, waitForOAuthCode, } from "./auth.js";
4
3
  import { TickTickClient } from "./client.js";
@@ -14,26 +13,59 @@ const CONFIG_KEYS = new Set([
14
13
  "apiBaseUrl",
15
14
  "authBaseUrl",
16
15
  ]);
17
- const program = new Command();
18
- program
19
- .name("ticktick")
20
- .description("Simple CLI wrapper for the TickTick Open API")
21
- .option("--config-file <path>", "Custom config file path")
22
- .option("--service <service>", 'Service: "ticktick" or "dida365"')
23
- .option("--api-base-url <url>", "Override the API base URL")
24
- .option("--auth-base-url <url>", "Override the OAuth base URL")
25
- .option("--access-token <token>", "Override the access token for a single command")
26
- .option("--client-id <id>", "Override the OAuth client id")
27
- .option("--client-secret <secret>", "Override the OAuth client secret")
28
- .option("--redirect-uri <uri>", "Override the OAuth redirect URI")
29
- .option("--scopes <scopes>", "Override the OAuth scopes");
30
- buildAuthCommands(program);
31
- buildConfigCommands(program);
32
- buildTaskCommands(program);
33
- buildProjectCommands(program);
34
- buildRequestCommand(program);
35
- program.parseAsync(process.argv).catch(handleError);
36
- function buildAuthCommands(root) {
16
+ export const defaultCliDependencies = {
17
+ buildAuthorizationUrl,
18
+ exchangeAuthorizationCode,
19
+ isLoopbackRedirect,
20
+ loadJsonValue,
21
+ loadStoredConfig,
22
+ maskSecret,
23
+ mergeDefined,
24
+ openBrowser,
25
+ printJson,
26
+ resolveRuntimeConfig,
27
+ saveStoredConfig,
28
+ validateService,
29
+ waitForOAuthCode,
30
+ createClient: (config) => new TickTickClient(config),
31
+ stderr: (text) => process.stderr.write(text),
32
+ };
33
+ export function createProgram(dependencies = defaultCliDependencies) {
34
+ const program = new Command();
35
+ program
36
+ .name("ticktick")
37
+ .description("CLI wrapper for the TickTick Open API")
38
+ .option("--config-file <path>", "Custom config file path")
39
+ .option("--service <service>", 'Service: "ticktick" or "dida365"')
40
+ .option("--api-base-url <url>", "Override the API base URL")
41
+ .option("--auth-base-url <url>", "Override the OAuth base URL")
42
+ .option("--access-token <token>", "Override the access token for a single command")
43
+ .option("--client-id <id>", "Override the OAuth client id")
44
+ .option("--client-secret <secret>", "Override the OAuth client secret")
45
+ .option("--redirect-uri <uri>", "Override the OAuth redirect URI")
46
+ .option("--scopes <scopes>", "Override the OAuth scopes");
47
+ buildAuthCommands(program, dependencies);
48
+ buildConfigCommands(program, dependencies);
49
+ buildTaskCommands(program, dependencies);
50
+ buildProjectCommands(program, dependencies);
51
+ buildRequestCommand(program, dependencies);
52
+ return program;
53
+ }
54
+ export async function main(argv = process.argv, dependencies = defaultCliDependencies) {
55
+ const program = createProgram(dependencies);
56
+ try {
57
+ await program.parseAsync(argv);
58
+ }
59
+ catch (error) {
60
+ handleError(error, dependencies);
61
+ }
62
+ }
63
+ export function handleError(error, dependencies = defaultCliDependencies) {
64
+ const message = error instanceof Error ? error.message : String(error);
65
+ dependencies.stderr(`${message}\n`);
66
+ process.exitCode = 1;
67
+ }
68
+ function buildAuthCommands(root, dependencies) {
37
69
  const auth = root.command("auth").description("OAuth and token management");
38
70
  auth
39
71
  .command("url")
@@ -42,10 +74,9 @@ function buildAuthCommands(root) {
42
74
  .action(async (...args) => {
43
75
  const command = args.at(-1);
44
76
  const options = command.optsWithGlobals();
45
- const config = await resolveRuntimeConfig(runtimeOverrides(options));
77
+ const config = await dependencies.resolveRuntimeConfig(runtimeOverrides(options, dependencies));
46
78
  requireClientId(config);
47
- const result = buildAuthorizationUrl(config, options.state);
48
- printJson(result);
79
+ dependencies.printJson(dependencies.buildAuthorizationUrl(config, options.state));
49
80
  });
50
81
  auth
51
82
  .command("login")
@@ -54,23 +85,23 @@ function buildAuthCommands(root) {
54
85
  .action(async (...args) => {
55
86
  const command = args.at(-1);
56
87
  const options = command.optsWithGlobals();
57
- const config = await resolveRuntimeConfig(runtimeOverrides(options));
88
+ const config = await dependencies.resolveRuntimeConfig(runtimeOverrides(options, dependencies));
58
89
  requireClientCredentials(config);
59
- const { url, state } = buildAuthorizationUrl(config);
60
- process.stderr.write(`Authorize URL:\n${url}\n`);
61
- if (!isLoopbackRedirect(config.redirectUri)) {
62
- printJson({
90
+ const { url, state } = dependencies.buildAuthorizationUrl(config);
91
+ dependencies.stderr(`Authorize URL:\n${url}\n`);
92
+ if (!dependencies.isLoopbackRedirect(config.redirectUri)) {
93
+ dependencies.printJson({
63
94
  ok: false,
64
95
  reason: "redirect_uri is not a local HTTP callback. Open the URL above and then run `ticktick auth exchange <code>`.",
65
96
  });
66
97
  return;
67
98
  }
68
- const codePromise = waitForOAuthCode(config.redirectUri, state, options.timeoutMs);
69
- await openBrowser(url);
99
+ const codePromise = dependencies.waitForOAuthCode(config.redirectUri, state, options.timeoutMs);
100
+ await dependencies.openBrowser(url);
70
101
  const code = await codePromise;
71
- const token = await exchangeAuthorizationCode(config, code);
72
- await persistConfig(config, token.access_token);
73
- printJson(token);
102
+ const token = await dependencies.exchangeAuthorizationCode(config, code);
103
+ await persistConfig(config, token, dependencies);
104
+ dependencies.printJson(token);
74
105
  });
75
106
  auth
76
107
  .command("exchange <code>")
@@ -78,11 +109,11 @@ function buildAuthCommands(root) {
78
109
  .action(async (...args) => {
79
110
  const command = args.at(-1);
80
111
  const [code] = args;
81
- const config = await resolveRuntimeConfig(runtimeOverrides(command.optsWithGlobals()));
112
+ const config = await dependencies.resolveRuntimeConfig(runtimeOverrides(command.optsWithGlobals(), dependencies));
82
113
  requireClientCredentials(config);
83
- const token = await exchangeAuthorizationCode(config, code);
84
- await persistConfig(config, token.access_token);
85
- printJson(token);
114
+ const token = await dependencies.exchangeAuthorizationCode(config, code);
115
+ await persistConfig(config, token, dependencies);
116
+ dependencies.printJson(token);
86
117
  });
87
118
  auth
88
119
  .command("status")
@@ -91,15 +122,19 @@ function buildAuthCommands(root) {
91
122
  .action(async (...args) => {
92
123
  const command = args.at(-1);
93
124
  const options = command.optsWithGlobals();
94
- const config = await resolveRuntimeConfig(runtimeOverrides(options));
95
- printJson({
125
+ const config = await dependencies.resolveRuntimeConfig(runtimeOverrides(options, dependencies));
126
+ dependencies.printJson({
96
127
  service: config.service,
97
128
  configFile: config.configFile,
98
129
  clientId: config.clientId,
99
- clientSecret: options.showSecrets ? config.clientSecret : maskSecret(config.clientSecret),
130
+ clientSecret: options.showSecrets
131
+ ? config.clientSecret
132
+ : dependencies.maskSecret(config.clientSecret),
100
133
  redirectUri: config.redirectUri,
101
134
  scopes: config.scopes,
102
- accessToken: options.showSecrets ? config.accessToken : maskSecret(config.accessToken),
135
+ accessToken: options.showSecrets
136
+ ? config.accessToken
137
+ : dependencies.maskSecret(config.accessToken),
103
138
  apiBaseUrl: config.apiBaseUrl,
104
139
  authBaseUrl: config.authBaseUrl,
105
140
  });
@@ -109,14 +144,14 @@ function buildAuthCommands(root) {
109
144
  .description("Remove the stored access token from the config file")
110
145
  .action(async (...args) => {
111
146
  const command = args.at(-1);
112
- const config = await resolveRuntimeConfig(runtimeOverrides(command.optsWithGlobals()));
113
- const stored = await loadStoredConfig(config.configFile);
147
+ const config = await dependencies.resolveRuntimeConfig(runtimeOverrides(command.optsWithGlobals(), dependencies));
148
+ const stored = await dependencies.loadStoredConfig(config.configFile);
114
149
  delete stored.accessToken;
115
- await saveStoredConfig(config.configFile, stored);
116
- printJson({ ok: true });
150
+ await dependencies.saveStoredConfig(config.configFile, stored);
151
+ dependencies.printJson({ ok: true });
117
152
  });
118
153
  }
119
- function buildConfigCommands(root) {
154
+ function buildConfigCommands(root, dependencies) {
120
155
  const config = root.command("config").description("Read and write local CLI config");
121
156
  config
122
157
  .command("show")
@@ -125,15 +160,19 @@ function buildConfigCommands(root) {
125
160
  .action(async (...args) => {
126
161
  const command = args.at(-1);
127
162
  const options = command.optsWithGlobals();
128
- const resolved = await resolveRuntimeConfig(runtimeOverrides(options));
129
- printJson({
163
+ const resolved = await dependencies.resolveRuntimeConfig(runtimeOverrides(options, dependencies));
164
+ dependencies.printJson({
130
165
  service: resolved.service,
131
166
  configFile: resolved.configFile,
132
167
  clientId: resolved.clientId,
133
- clientSecret: options.showSecrets ? resolved.clientSecret : maskSecret(resolved.clientSecret),
168
+ clientSecret: options.showSecrets
169
+ ? resolved.clientSecret
170
+ : dependencies.maskSecret(resolved.clientSecret),
134
171
  redirectUri: resolved.redirectUri,
135
172
  scopes: resolved.scopes,
136
- accessToken: options.showSecrets ? resolved.accessToken : maskSecret(resolved.accessToken),
173
+ accessToken: options.showSecrets
174
+ ? resolved.accessToken
175
+ : dependencies.maskSecret(resolved.accessToken),
137
176
  apiBaseUrl: resolved.apiBaseUrl,
138
177
  authBaseUrl: resolved.authBaseUrl,
139
178
  });
@@ -144,15 +183,15 @@ function buildConfigCommands(root) {
144
183
  .action(async (...args) => {
145
184
  const command = args.at(-1);
146
185
  const [key, value] = args;
147
- const runtime = await resolveRuntimeConfig(runtimeOverrides(command.optsWithGlobals()));
186
+ const runtime = await dependencies.resolveRuntimeConfig(runtimeOverrides(command.optsWithGlobals(), dependencies));
148
187
  if (!CONFIG_KEYS.has(key)) {
149
188
  throw new Error(`Unsupported config key "${key}". Allowed keys: ${Array.from(CONFIG_KEYS).join(", ")}`);
150
189
  }
151
- const stored = await loadStoredConfig(runtime.configFile);
152
- const normalizedValue = key === "service" ? validateService(value) : value;
190
+ const stored = await dependencies.loadStoredConfig(runtime.configFile);
191
+ const normalizedValue = key === "service" ? dependencies.validateService(value) : value;
153
192
  const next = { ...stored, [key]: normalizedValue };
154
- await saveStoredConfig(runtime.configFile, next);
155
- printJson(next);
193
+ await dependencies.saveStoredConfig(runtime.configFile, next);
194
+ dependencies.printJson(next);
156
195
  });
157
196
  config
158
197
  .command("unset <key>")
@@ -160,24 +199,24 @@ function buildConfigCommands(root) {
160
199
  .action(async (...args) => {
161
200
  const command = args.at(-1);
162
201
  const [key] = args;
163
- const runtime = await resolveRuntimeConfig(runtimeOverrides(command.optsWithGlobals()));
202
+ const runtime = await dependencies.resolveRuntimeConfig(runtimeOverrides(command.optsWithGlobals(), dependencies));
164
203
  if (!CONFIG_KEYS.has(key)) {
165
204
  throw new Error(`Unsupported config key "${key}". Allowed keys: ${Array.from(CONFIG_KEYS).join(", ")}`);
166
205
  }
167
- const stored = await loadStoredConfig(runtime.configFile);
206
+ const stored = await dependencies.loadStoredConfig(runtime.configFile);
168
207
  delete stored[key];
169
- await saveStoredConfig(runtime.configFile, stored);
170
- printJson(stored);
208
+ await dependencies.saveStoredConfig(runtime.configFile, stored);
209
+ dependencies.printJson(stored);
171
210
  });
172
211
  }
173
- function buildTaskCommands(root) {
212
+ function buildTaskCommands(root, dependencies) {
174
213
  const task = root.command("task").description("Task endpoints");
175
214
  task
176
215
  .command("get <projectId> <taskId>")
177
216
  .description("Get a task by project id and task id")
178
217
  .action(async (...args) => {
179
218
  const [projectId, taskId] = args;
180
- await runClientCommand(args, (client) => client.getTask(projectId, taskId));
219
+ await runClientCommand(args, dependencies, (client) => client.getTask(projectId, taskId));
181
220
  });
182
221
  withJsonBody(task
183
222
  .command("create")
@@ -207,11 +246,11 @@ function buildTaskCommands(root) {
207
246
  priority: options.priority,
208
247
  sortOrder: options.sortOrder,
209
248
  isAllDay: options.allDay,
210
- }));
249
+ }, dependencies));
211
250
  if (!payload.projectId || !payload.title) {
212
251
  throw new Error("Task creation requires both projectId and title.");
213
252
  }
214
- await runClientCommand(args, (client) => client.createTask(payload));
253
+ await runClientCommand(args, dependencies, (client) => client.createTask(payload));
215
254
  });
216
255
  withJsonBody(task
217
256
  .command("update <taskId>")
@@ -243,26 +282,26 @@ function buildTaskCommands(root) {
243
282
  priority: options.priority,
244
283
  sortOrder: options.sortOrder,
245
284
  isAllDay: options.allDay,
246
- }));
285
+ }, dependencies));
247
286
  payload.id = taskId;
248
287
  if (!payload.projectId) {
249
288
  throw new Error("Task update requires projectId.");
250
289
  }
251
- await runClientCommand(args, (client) => client.updateTask(taskId, payload));
290
+ await runClientCommand(args, dependencies, (client) => client.updateTask(taskId, payload));
252
291
  });
253
292
  task
254
293
  .command("complete <projectId> <taskId>")
255
294
  .description("Complete a task")
256
295
  .action(async (...args) => {
257
296
  const [projectId, taskId] = args;
258
- await runClientCommand(args, (client) => client.completeTask(projectId, taskId));
297
+ await runClientCommand(args, dependencies, (client) => client.completeTask(projectId, taskId));
259
298
  });
260
299
  task
261
300
  .command("delete <projectId> <taskId>")
262
301
  .description("Delete a task")
263
302
  .action(async (...args) => {
264
303
  const [projectId, taskId] = args;
265
- await runClientCommand(args, (client) => client.deleteTask(projectId, taskId));
304
+ await runClientCommand(args, dependencies, (client) => client.deleteTask(projectId, taskId));
266
305
  });
267
306
  withJsonBody(task
268
307
  .command("move")
@@ -272,7 +311,7 @@ function buildTaskCommands(root) {
272
311
  .option("--task-id <id>", "Task id")).action(async (...args) => {
273
312
  const command = args.at(-1);
274
313
  const options = command.optsWithGlobals();
275
- let payload = (await loadJsonValue(options.json, options.jsonFile));
314
+ let payload = (await dependencies.loadJsonValue(options.json, options.jsonFile));
276
315
  if (!payload) {
277
316
  if (!options.fromProjectId || !options.toProjectId || !options.taskId) {
278
317
  throw new Error("Provide --json/--json-file or all of --from-project-id, --to-project-id, and --task-id.");
@@ -288,7 +327,7 @@ function buildTaskCommands(root) {
288
327
  if (!Array.isArray(payload)) {
289
328
  throw new Error("Move payload must be a JSON array.");
290
329
  }
291
- await runClientCommand(args, (client) => client.moveTasks(payload));
330
+ await runClientCommand(args, dependencies, (client) => client.moveTasks(payload));
292
331
  });
293
332
  withJsonBody(task
294
333
  .command("completed")
@@ -302,8 +341,8 @@ function buildTaskCommands(root) {
302
341
  projectIds: options.projectId && options.projectId.length > 0 ? options.projectId : undefined,
303
342
  startDate: options.startDate,
304
343
  endDate: options.endDate,
305
- }));
306
- await runClientCommand(args, (client) => client.listCompletedTasks(payload));
344
+ }, dependencies));
345
+ await runClientCommand(args, dependencies, (client) => client.listCompletedTasks(payload));
307
346
  });
308
347
  withJsonBody(task
309
348
  .command("filter")
@@ -323,31 +362,31 @@ function buildTaskCommands(root) {
323
362
  priority: options.priority && options.priority.length > 0 ? options.priority : undefined,
324
363
  tag: options.tag && options.tag.length > 0 ? options.tag : undefined,
325
364
  status: options.status && options.status.length > 0 ? options.status : undefined,
326
- }));
327
- await runClientCommand(args, (client) => client.filterTasks(payload));
365
+ }, dependencies));
366
+ await runClientCommand(args, dependencies, (client) => client.filterTasks(payload));
328
367
  });
329
368
  }
330
- function buildProjectCommands(root) {
369
+ function buildProjectCommands(root, dependencies) {
331
370
  const project = root.command("project").description("Project endpoints");
332
371
  project
333
372
  .command("list")
334
373
  .description("List user projects")
335
374
  .action(async (...args) => {
336
- await runClientCommand(args, (client) => client.listProjects());
375
+ await runClientCommand(args, dependencies, (client) => client.listProjects());
337
376
  });
338
377
  project
339
378
  .command("get <projectId>")
340
379
  .description("Get a project by id")
341
380
  .action(async (...args) => {
342
381
  const [projectId] = args;
343
- await runClientCommand(args, (client) => client.getProject(projectId));
382
+ await runClientCommand(args, dependencies, (client) => client.getProject(projectId));
344
383
  });
345
384
  project
346
385
  .command("data <projectId>")
347
386
  .description("Get a project together with tasks and columns")
348
387
  .action(async (...args) => {
349
388
  const [projectId] = args;
350
- await runClientCommand(args, (client) => client.getProjectData(projectId));
389
+ await runClientCommand(args, dependencies, (client) => client.getProjectData(projectId));
351
390
  });
352
391
  withJsonBody(project
353
392
  .command("create")
@@ -365,11 +404,11 @@ function buildProjectCommands(root) {
365
404
  sortOrder: options.sortOrder,
366
405
  viewMode: options.viewMode,
367
406
  kind: options.kind,
368
- }));
407
+ }, dependencies));
369
408
  if (!payload.name) {
370
409
  throw new Error("Project creation requires name.");
371
410
  }
372
- await runClientCommand(args, (client) => client.createProject(payload));
411
+ await runClientCommand(args, dependencies, (client) => client.createProject(payload));
373
412
  });
374
413
  withJsonBody(project
375
414
  .command("update <projectId>")
@@ -388,18 +427,18 @@ function buildProjectCommands(root) {
388
427
  sortOrder: options.sortOrder,
389
428
  viewMode: options.viewMode,
390
429
  kind: options.kind,
391
- }));
392
- await runClientCommand(args, (client) => client.updateProject(projectId, payload));
430
+ }, dependencies));
431
+ await runClientCommand(args, dependencies, (client) => client.updateProject(projectId, payload));
393
432
  });
394
433
  project
395
434
  .command("delete <projectId>")
396
435
  .description("Delete a project")
397
436
  .action(async (...args) => {
398
437
  const [projectId] = args;
399
- await runClientCommand(args, (client) => client.deleteProject(projectId));
438
+ await runClientCommand(args, dependencies, (client) => client.deleteProject(projectId));
400
439
  });
401
440
  }
402
- function buildRequestCommand(root) {
441
+ function buildRequestCommand(root, dependencies) {
403
442
  withJsonBody(root
404
443
  .command("request <method> <path>")
405
444
  .description("Send a raw request to the configured API base URL, or to a full URL")
@@ -407,11 +446,13 @@ function buildRequestCommand(root) {
407
446
  const command = args.at(-1);
408
447
  const [method, path] = args;
409
448
  const options = command.optsWithGlobals();
410
- const config = await resolveRuntimeConfig(runtimeOverrides(options));
411
- const client = new TickTickClient(config);
412
- const body = await loadJsonValue(options.json, options.jsonFile);
413
- const result = await client.requestRaw(method.toUpperCase(), path, body, { auth: options.auth });
414
- printJson(result ?? { ok: true });
449
+ const config = await dependencies.resolveRuntimeConfig(runtimeOverrides(options, dependencies));
450
+ const client = dependencies.createClient(config);
451
+ const body = await dependencies.loadJsonValue(options.json, options.jsonFile);
452
+ const result = await client.requestRaw(method.toUpperCase(), path, body, {
453
+ auth: options.auth,
454
+ });
455
+ dependencies.printJson(result ?? { ok: true });
415
456
  });
416
457
  }
417
458
  function withJsonBody(command) {
@@ -419,10 +460,10 @@ function withJsonBody(command) {
419
460
  .option("--json <json>", "Inline JSON body")
420
461
  .option("--json-file <path>", "Path to a JSON body file");
421
462
  }
422
- function runtimeOverrides(options) {
463
+ function runtimeOverrides(options, dependencies) {
423
464
  return {
424
465
  configFile: options.configFile,
425
- service: options.service ? validateService(options.service) : undefined,
466
+ service: options.service ? dependencies.validateService(options.service) : undefined,
426
467
  apiBaseUrl: options.apiBaseUrl,
427
468
  authBaseUrl: options.authBaseUrl,
428
469
  accessToken: options.accessToken,
@@ -432,28 +473,28 @@ function runtimeOverrides(options) {
432
473
  scopes: options.scopes,
433
474
  };
434
475
  }
435
- async function runClientCommand(args, runner) {
476
+ async function runClientCommand(args, dependencies, runner) {
436
477
  const command = args.at(-1);
437
- const config = await resolveRuntimeConfig(runtimeOverrides(command.optsWithGlobals()));
438
- const client = new TickTickClient(config);
478
+ const config = await dependencies.resolveRuntimeConfig(runtimeOverrides(command.optsWithGlobals(), dependencies));
479
+ const client = dependencies.createClient(config);
439
480
  const result = await runner(client, config);
440
- printJson(result ?? { ok: true });
481
+ dependencies.printJson(result ?? { ok: true });
441
482
  }
442
- async function loadObjectPayload(options, flags) {
443
- const loaded = await loadJsonValue(options.json, options.jsonFile);
483
+ async function loadObjectPayload(options, flags, dependencies) {
484
+ const loaded = await dependencies.loadJsonValue(options.json, options.jsonFile);
444
485
  if (loaded === undefined) {
445
- return mergeDefined({}, flags);
486
+ return dependencies.mergeDefined({}, flags);
446
487
  }
447
488
  if (!isPlainObject(loaded)) {
448
489
  throw new Error("Expected a JSON object payload.");
449
490
  }
450
- return mergeDefined(loaded, flags);
491
+ return dependencies.mergeDefined(loaded, flags);
451
492
  }
452
493
  function isPlainObject(value) {
453
494
  return typeof value === "object" && value !== null && !Array.isArray(value);
454
495
  }
455
- async function persistConfig(runtime, accessToken) {
456
- const stored = await loadStoredConfig(runtime.configFile);
496
+ async function persistConfig(runtime, token, dependencies) {
497
+ const stored = await dependencies.loadStoredConfig(runtime.configFile);
457
498
  const next = {
458
499
  ...stored,
459
500
  service: runtime.service,
@@ -463,9 +504,9 @@ async function persistConfig(runtime, accessToken) {
463
504
  scopes: runtime.scopes,
464
505
  apiBaseUrl: runtime.apiBaseUrl,
465
506
  authBaseUrl: runtime.authBaseUrl,
466
- accessToken,
507
+ accessToken: token.access_token,
467
508
  };
468
- await saveStoredConfig(runtime.configFile, next);
509
+ await dependencies.saveStoredConfig(runtime.configFile, next);
469
510
  }
470
511
  function requireClientCredentials(config) {
471
512
  if (!config.clientId || !config.clientSecret) {
@@ -483,8 +524,3 @@ function collectString(value, previous = []) {
483
524
  function collectInteger(value, previous = []) {
484
525
  return [...previous, parseInteger(value)];
485
526
  }
486
- function handleError(error) {
487
- const message = error instanceof Error ? error.message : String(error);
488
- process.stderr.write(`${message}\n`);
489
- process.exitCode = 1;
490
- }
package/dist/config.js CHANGED
@@ -13,15 +13,18 @@ const SERVICE_DEFAULTS = {
13
13
  authBaseUrl: "https://dida365.com",
14
14
  },
15
15
  };
16
- export function defaultConfigFilePath() {
17
- const appData = process.env.APPDATA;
18
- if (process.platform === "win32" && appData) {
19
- return path.join(appData, "ticktick-cli", "config.json");
16
+ export function defaultConfigFilePath(options = {}) {
17
+ const platform = options.platform ?? process.platform;
18
+ const appData = options.appData ?? process.env.APPDATA;
19
+ const homeDir = options.homeDir ?? os.homedir();
20
+ const pathModule = platform === "win32" ? path.win32 : path.posix;
21
+ if (platform === "win32" && appData) {
22
+ return pathModule.join(appData, "ticktick-cli", "config.json");
20
23
  }
21
- if (process.platform === "darwin") {
22
- return path.join(os.homedir(), "Library", "Application Support", "ticktick-cli", "config.json");
24
+ if (platform === "darwin") {
25
+ return pathModule.join(homeDir, "Library", "Application Support", "ticktick-cli", "config.json");
23
26
  }
24
- return path.join(os.homedir(), ".config", "ticktick-cli", "config.json");
27
+ return pathModule.join(homeDir, ".config", "ticktick-cli", "config.json");
25
28
  }
26
29
  export async function loadStoredConfig(configFile) {
27
30
  try {
package/dist/utils.js CHANGED
@@ -25,24 +25,24 @@ export function parseInteger(value) {
25
25
  }
26
26
  return parsed;
27
27
  }
28
- export async function maybeReadStdin() {
29
- if (process.stdin.isTTY) {
28
+ export async function maybeReadStdin(input = process.stdin) {
29
+ if (input.isTTY) {
30
30
  return undefined;
31
31
  }
32
32
  const chunks = [];
33
- for await (const chunk of process.stdin) {
33
+ for await (const chunk of input) {
34
34
  chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
35
35
  }
36
36
  const text = Buffer.concat(chunks).toString("utf8").trim();
37
37
  return text.length > 0 ? text : undefined;
38
38
  }
39
- export async function loadJsonValue(inlineJson, jsonFile) {
39
+ export async function loadJsonValue(inlineJson, jsonFile, input = process.stdin) {
40
40
  let raw = inlineJson;
41
41
  if (jsonFile) {
42
42
  raw = await readFile(jsonFile, "utf8");
43
43
  }
44
44
  if (!raw) {
45
- raw = await maybeReadStdin();
45
+ raw = await maybeReadStdin(input);
46
46
  }
47
47
  if (!raw) {
48
48
  return undefined;
@@ -55,8 +55,8 @@ export async function loadJsonValue(inlineJson, jsonFile) {
55
55
  throw new Error(`Failed to parse JSON input: ${message}`);
56
56
  }
57
57
  }
58
- export function printJson(value) {
59
- process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
58
+ export function printJson(value, write = (text) => process.stdout.write(text)) {
59
+ write(`${JSON.stringify(value, null, 2)}\n`);
60
60
  }
61
61
  export function maskSecret(value) {
62
62
  if (!value) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ticktick-cli",
3
- "version": "0.1.1",
4
- "description": "Simple CLI wrapper for the TickTick Open API",
3
+ "version": "1.0.1",
4
+ "description": "CLI wrapper for the TickTick Open API",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "dist",
@@ -9,15 +9,19 @@
9
9
  "LICENSE"
10
10
  ],
11
11
  "bin": {
12
- "ticktick": "dist/cli.js"
12
+ "ticktick": "dist/bin.js",
13
+ "tt": "dist/bin.js"
13
14
  },
14
15
  "scripts": {
15
16
  "build": "npm run clean && tsc -p tsconfig.json",
16
- "check": "npm run build",
17
+ "build:coverage": "npm run clean:coverage && tsc -p tsconfig.coverage.json",
18
+ "check": "npm run coverage",
17
19
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
20
+ "clean:coverage": "node -e \"require('node:fs').rmSync('.coverage-dist',{recursive:true,force:true})\"",
18
21
  "dev": "tsx src/cli.ts",
19
- "prepack": "npm run test",
20
- "test": "npm run build && node --import tsx --test src/test/*.test.ts"
22
+ "prepack": "npm run coverage",
23
+ "test": "npm run build && node --import tsx --test src/test/*.test.ts",
24
+ "coverage": "npm run build:coverage && node --experimental-test-coverage --test-coverage-lines=100 --test-coverage-functions=100 --test-coverage-branches=100 --test-coverage-include=.coverage-dist/*.js --test-coverage-exclude=.coverage-dist/bin.js --test .coverage-dist/test/*.test.js"
21
25
  },
22
26
  "keywords": [
23
27
  "ticktick",