ticktick-cli 0.1.1 → 1.0.2

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.2` 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,14 @@ 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
+ Build the CLI:
209
+
210
+ ```bash
211
+ npm run build
212
+ ```
213
+
183
214
  ## Notes
184
215
 
185
216
  - 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,5 +1,5 @@
1
- #!/usr/bin/env node
2
1
  import { Command } from "commander";
2
+ import { pathToFileURL } from "node:url";
3
3
  import { buildAuthorizationUrl, exchangeAuthorizationCode, isLoopbackRedirect, openBrowser, waitForOAuthCode, } from "./auth.js";
4
4
  import { TickTickClient } from "./client.js";
5
5
  import { loadStoredConfig, resolveRuntimeConfig, saveStoredConfig, validateService, } from "./config.js";
@@ -14,26 +14,59 @@ const CONFIG_KEYS = new Set([
14
14
  "apiBaseUrl",
15
15
  "authBaseUrl",
16
16
  ]);
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) {
17
+ export const defaultCliDependencies = {
18
+ buildAuthorizationUrl,
19
+ exchangeAuthorizationCode,
20
+ isLoopbackRedirect,
21
+ loadJsonValue,
22
+ loadStoredConfig,
23
+ maskSecret,
24
+ mergeDefined,
25
+ openBrowser,
26
+ printJson,
27
+ resolveRuntimeConfig,
28
+ saveStoredConfig,
29
+ validateService,
30
+ waitForOAuthCode,
31
+ createClient: (config) => new TickTickClient(config),
32
+ stderr: (text) => process.stderr.write(text),
33
+ };
34
+ export function createProgram(dependencies = defaultCliDependencies) {
35
+ const program = new Command();
36
+ program
37
+ .name("ticktick")
38
+ .description("CLI wrapper for the TickTick Open API")
39
+ .option("--config-file <path>", "Custom config file path")
40
+ .option("--service <service>", 'Service: "ticktick" or "dida365"')
41
+ .option("--api-base-url <url>", "Override the API base URL")
42
+ .option("--auth-base-url <url>", "Override the OAuth base URL")
43
+ .option("--access-token <token>", "Override the access token for a single command")
44
+ .option("--client-id <id>", "Override the OAuth client id")
45
+ .option("--client-secret <secret>", "Override the OAuth client secret")
46
+ .option("--redirect-uri <uri>", "Override the OAuth redirect URI")
47
+ .option("--scopes <scopes>", "Override the OAuth scopes");
48
+ buildAuthCommands(program, dependencies);
49
+ buildConfigCommands(program, dependencies);
50
+ buildTaskCommands(program, dependencies);
51
+ buildProjectCommands(program, dependencies);
52
+ buildRequestCommand(program, dependencies);
53
+ return program;
54
+ }
55
+ export async function main(argv = process.argv, dependencies = defaultCliDependencies) {
56
+ const program = createProgram(dependencies);
57
+ try {
58
+ await program.parseAsync(argv);
59
+ }
60
+ catch (error) {
61
+ handleError(error, dependencies);
62
+ }
63
+ }
64
+ export function handleError(error, dependencies = defaultCliDependencies) {
65
+ const message = error instanceof Error ? error.message : String(error);
66
+ dependencies.stderr(`${message}\n`);
67
+ process.exitCode = 1;
68
+ }
69
+ function buildAuthCommands(root, dependencies) {
37
70
  const auth = root.command("auth").description("OAuth and token management");
38
71
  auth
39
72
  .command("url")
@@ -42,10 +75,9 @@ function buildAuthCommands(root) {
42
75
  .action(async (...args) => {
43
76
  const command = args.at(-1);
44
77
  const options = command.optsWithGlobals();
45
- const config = await resolveRuntimeConfig(runtimeOverrides(options));
78
+ const config = await dependencies.resolveRuntimeConfig(runtimeOverrides(options, dependencies));
46
79
  requireClientId(config);
47
- const result = buildAuthorizationUrl(config, options.state);
48
- printJson(result);
80
+ dependencies.printJson(dependencies.buildAuthorizationUrl(config, options.state));
49
81
  });
50
82
  auth
51
83
  .command("login")
@@ -54,23 +86,23 @@ function buildAuthCommands(root) {
54
86
  .action(async (...args) => {
55
87
  const command = args.at(-1);
56
88
  const options = command.optsWithGlobals();
57
- const config = await resolveRuntimeConfig(runtimeOverrides(options));
89
+ const config = await dependencies.resolveRuntimeConfig(runtimeOverrides(options, dependencies));
58
90
  requireClientCredentials(config);
59
- const { url, state } = buildAuthorizationUrl(config);
60
- process.stderr.write(`Authorize URL:\n${url}\n`);
61
- if (!isLoopbackRedirect(config.redirectUri)) {
62
- printJson({
91
+ const { url, state } = dependencies.buildAuthorizationUrl(config);
92
+ dependencies.stderr(`Authorize URL:\n${url}\n`);
93
+ if (!dependencies.isLoopbackRedirect(config.redirectUri)) {
94
+ dependencies.printJson({
63
95
  ok: false,
64
96
  reason: "redirect_uri is not a local HTTP callback. Open the URL above and then run `ticktick auth exchange <code>`.",
65
97
  });
66
98
  return;
67
99
  }
68
- const codePromise = waitForOAuthCode(config.redirectUri, state, options.timeoutMs);
69
- await openBrowser(url);
100
+ const codePromise = dependencies.waitForOAuthCode(config.redirectUri, state, options.timeoutMs);
101
+ await dependencies.openBrowser(url);
70
102
  const code = await codePromise;
71
- const token = await exchangeAuthorizationCode(config, code);
72
- await persistConfig(config, token.access_token);
73
- printJson(token);
103
+ const token = await dependencies.exchangeAuthorizationCode(config, code);
104
+ await persistConfig(config, token, dependencies);
105
+ dependencies.printJson(token);
74
106
  });
75
107
  auth
76
108
  .command("exchange <code>")
@@ -78,11 +110,11 @@ function buildAuthCommands(root) {
78
110
  .action(async (...args) => {
79
111
  const command = args.at(-1);
80
112
  const [code] = args;
81
- const config = await resolveRuntimeConfig(runtimeOverrides(command.optsWithGlobals()));
113
+ const config = await dependencies.resolveRuntimeConfig(runtimeOverrides(command.optsWithGlobals(), dependencies));
82
114
  requireClientCredentials(config);
83
- const token = await exchangeAuthorizationCode(config, code);
84
- await persistConfig(config, token.access_token);
85
- printJson(token);
115
+ const token = await dependencies.exchangeAuthorizationCode(config, code);
116
+ await persistConfig(config, token, dependencies);
117
+ dependencies.printJson(token);
86
118
  });
87
119
  auth
88
120
  .command("status")
@@ -91,15 +123,19 @@ function buildAuthCommands(root) {
91
123
  .action(async (...args) => {
92
124
  const command = args.at(-1);
93
125
  const options = command.optsWithGlobals();
94
- const config = await resolveRuntimeConfig(runtimeOverrides(options));
95
- printJson({
126
+ const config = await dependencies.resolveRuntimeConfig(runtimeOverrides(options, dependencies));
127
+ dependencies.printJson({
96
128
  service: config.service,
97
129
  configFile: config.configFile,
98
130
  clientId: config.clientId,
99
- clientSecret: options.showSecrets ? config.clientSecret : maskSecret(config.clientSecret),
131
+ clientSecret: options.showSecrets
132
+ ? config.clientSecret
133
+ : dependencies.maskSecret(config.clientSecret),
100
134
  redirectUri: config.redirectUri,
101
135
  scopes: config.scopes,
102
- accessToken: options.showSecrets ? config.accessToken : maskSecret(config.accessToken),
136
+ accessToken: options.showSecrets
137
+ ? config.accessToken
138
+ : dependencies.maskSecret(config.accessToken),
103
139
  apiBaseUrl: config.apiBaseUrl,
104
140
  authBaseUrl: config.authBaseUrl,
105
141
  });
@@ -109,14 +145,14 @@ function buildAuthCommands(root) {
109
145
  .description("Remove the stored access token from the config file")
110
146
  .action(async (...args) => {
111
147
  const command = args.at(-1);
112
- const config = await resolveRuntimeConfig(runtimeOverrides(command.optsWithGlobals()));
113
- const stored = await loadStoredConfig(config.configFile);
148
+ const config = await dependencies.resolveRuntimeConfig(runtimeOverrides(command.optsWithGlobals(), dependencies));
149
+ const stored = await dependencies.loadStoredConfig(config.configFile);
114
150
  delete stored.accessToken;
115
- await saveStoredConfig(config.configFile, stored);
116
- printJson({ ok: true });
151
+ await dependencies.saveStoredConfig(config.configFile, stored);
152
+ dependencies.printJson({ ok: true });
117
153
  });
118
154
  }
119
- function buildConfigCommands(root) {
155
+ function buildConfigCommands(root, dependencies) {
120
156
  const config = root.command("config").description("Read and write local CLI config");
121
157
  config
122
158
  .command("show")
@@ -125,15 +161,19 @@ function buildConfigCommands(root) {
125
161
  .action(async (...args) => {
126
162
  const command = args.at(-1);
127
163
  const options = command.optsWithGlobals();
128
- const resolved = await resolveRuntimeConfig(runtimeOverrides(options));
129
- printJson({
164
+ const resolved = await dependencies.resolveRuntimeConfig(runtimeOverrides(options, dependencies));
165
+ dependencies.printJson({
130
166
  service: resolved.service,
131
167
  configFile: resolved.configFile,
132
168
  clientId: resolved.clientId,
133
- clientSecret: options.showSecrets ? resolved.clientSecret : maskSecret(resolved.clientSecret),
169
+ clientSecret: options.showSecrets
170
+ ? resolved.clientSecret
171
+ : dependencies.maskSecret(resolved.clientSecret),
134
172
  redirectUri: resolved.redirectUri,
135
173
  scopes: resolved.scopes,
136
- accessToken: options.showSecrets ? resolved.accessToken : maskSecret(resolved.accessToken),
174
+ accessToken: options.showSecrets
175
+ ? resolved.accessToken
176
+ : dependencies.maskSecret(resolved.accessToken),
137
177
  apiBaseUrl: resolved.apiBaseUrl,
138
178
  authBaseUrl: resolved.authBaseUrl,
139
179
  });
@@ -144,15 +184,15 @@ function buildConfigCommands(root) {
144
184
  .action(async (...args) => {
145
185
  const command = args.at(-1);
146
186
  const [key, value] = args;
147
- const runtime = await resolveRuntimeConfig(runtimeOverrides(command.optsWithGlobals()));
187
+ const runtime = await dependencies.resolveRuntimeConfig(runtimeOverrides(command.optsWithGlobals(), dependencies));
148
188
  if (!CONFIG_KEYS.has(key)) {
149
189
  throw new Error(`Unsupported config key "${key}". Allowed keys: ${Array.from(CONFIG_KEYS).join(", ")}`);
150
190
  }
151
- const stored = await loadStoredConfig(runtime.configFile);
152
- const normalizedValue = key === "service" ? validateService(value) : value;
191
+ const stored = await dependencies.loadStoredConfig(runtime.configFile);
192
+ const normalizedValue = key === "service" ? dependencies.validateService(value) : value;
153
193
  const next = { ...stored, [key]: normalizedValue };
154
- await saveStoredConfig(runtime.configFile, next);
155
- printJson(next);
194
+ await dependencies.saveStoredConfig(runtime.configFile, next);
195
+ dependencies.printJson(next);
156
196
  });
157
197
  config
158
198
  .command("unset <key>")
@@ -160,24 +200,24 @@ function buildConfigCommands(root) {
160
200
  .action(async (...args) => {
161
201
  const command = args.at(-1);
162
202
  const [key] = args;
163
- const runtime = await resolveRuntimeConfig(runtimeOverrides(command.optsWithGlobals()));
203
+ const runtime = await dependencies.resolveRuntimeConfig(runtimeOverrides(command.optsWithGlobals(), dependencies));
164
204
  if (!CONFIG_KEYS.has(key)) {
165
205
  throw new Error(`Unsupported config key "${key}". Allowed keys: ${Array.from(CONFIG_KEYS).join(", ")}`);
166
206
  }
167
- const stored = await loadStoredConfig(runtime.configFile);
207
+ const stored = await dependencies.loadStoredConfig(runtime.configFile);
168
208
  delete stored[key];
169
- await saveStoredConfig(runtime.configFile, stored);
170
- printJson(stored);
209
+ await dependencies.saveStoredConfig(runtime.configFile, stored);
210
+ dependencies.printJson(stored);
171
211
  });
172
212
  }
173
- function buildTaskCommands(root) {
213
+ function buildTaskCommands(root, dependencies) {
174
214
  const task = root.command("task").description("Task endpoints");
175
215
  task
176
216
  .command("get <projectId> <taskId>")
177
217
  .description("Get a task by project id and task id")
178
218
  .action(async (...args) => {
179
219
  const [projectId, taskId] = args;
180
- await runClientCommand(args, (client) => client.getTask(projectId, taskId));
220
+ await runClientCommand(args, dependencies, (client) => client.getTask(projectId, taskId));
181
221
  });
182
222
  withJsonBody(task
183
223
  .command("create")
@@ -207,11 +247,11 @@ function buildTaskCommands(root) {
207
247
  priority: options.priority,
208
248
  sortOrder: options.sortOrder,
209
249
  isAllDay: options.allDay,
210
- }));
250
+ }, dependencies));
211
251
  if (!payload.projectId || !payload.title) {
212
252
  throw new Error("Task creation requires both projectId and title.");
213
253
  }
214
- await runClientCommand(args, (client) => client.createTask(payload));
254
+ await runClientCommand(args, dependencies, (client) => client.createTask(payload));
215
255
  });
216
256
  withJsonBody(task
217
257
  .command("update <taskId>")
@@ -243,26 +283,26 @@ function buildTaskCommands(root) {
243
283
  priority: options.priority,
244
284
  sortOrder: options.sortOrder,
245
285
  isAllDay: options.allDay,
246
- }));
286
+ }, dependencies));
247
287
  payload.id = taskId;
248
288
  if (!payload.projectId) {
249
289
  throw new Error("Task update requires projectId.");
250
290
  }
251
- await runClientCommand(args, (client) => client.updateTask(taskId, payload));
291
+ await runClientCommand(args, dependencies, (client) => client.updateTask(taskId, payload));
252
292
  });
253
293
  task
254
294
  .command("complete <projectId> <taskId>")
255
295
  .description("Complete a task")
256
296
  .action(async (...args) => {
257
297
  const [projectId, taskId] = args;
258
- await runClientCommand(args, (client) => client.completeTask(projectId, taskId));
298
+ await runClientCommand(args, dependencies, (client) => client.completeTask(projectId, taskId));
259
299
  });
260
300
  task
261
301
  .command("delete <projectId> <taskId>")
262
302
  .description("Delete a task")
263
303
  .action(async (...args) => {
264
304
  const [projectId, taskId] = args;
265
- await runClientCommand(args, (client) => client.deleteTask(projectId, taskId));
305
+ await runClientCommand(args, dependencies, (client) => client.deleteTask(projectId, taskId));
266
306
  });
267
307
  withJsonBody(task
268
308
  .command("move")
@@ -272,7 +312,7 @@ function buildTaskCommands(root) {
272
312
  .option("--task-id <id>", "Task id")).action(async (...args) => {
273
313
  const command = args.at(-1);
274
314
  const options = command.optsWithGlobals();
275
- let payload = (await loadJsonValue(options.json, options.jsonFile));
315
+ let payload = (await dependencies.loadJsonValue(options.json, options.jsonFile));
276
316
  if (!payload) {
277
317
  if (!options.fromProjectId || !options.toProjectId || !options.taskId) {
278
318
  throw new Error("Provide --json/--json-file or all of --from-project-id, --to-project-id, and --task-id.");
@@ -288,7 +328,7 @@ function buildTaskCommands(root) {
288
328
  if (!Array.isArray(payload)) {
289
329
  throw new Error("Move payload must be a JSON array.");
290
330
  }
291
- await runClientCommand(args, (client) => client.moveTasks(payload));
331
+ await runClientCommand(args, dependencies, (client) => client.moveTasks(payload));
292
332
  });
293
333
  withJsonBody(task
294
334
  .command("completed")
@@ -302,8 +342,8 @@ function buildTaskCommands(root) {
302
342
  projectIds: options.projectId && options.projectId.length > 0 ? options.projectId : undefined,
303
343
  startDate: options.startDate,
304
344
  endDate: options.endDate,
305
- }));
306
- await runClientCommand(args, (client) => client.listCompletedTasks(payload));
345
+ }, dependencies));
346
+ await runClientCommand(args, dependencies, (client) => client.listCompletedTasks(payload));
307
347
  });
308
348
  withJsonBody(task
309
349
  .command("filter")
@@ -323,31 +363,31 @@ function buildTaskCommands(root) {
323
363
  priority: options.priority && options.priority.length > 0 ? options.priority : undefined,
324
364
  tag: options.tag && options.tag.length > 0 ? options.tag : undefined,
325
365
  status: options.status && options.status.length > 0 ? options.status : undefined,
326
- }));
327
- await runClientCommand(args, (client) => client.filterTasks(payload));
366
+ }, dependencies));
367
+ await runClientCommand(args, dependencies, (client) => client.filterTasks(payload));
328
368
  });
329
369
  }
330
- function buildProjectCommands(root) {
370
+ function buildProjectCommands(root, dependencies) {
331
371
  const project = root.command("project").description("Project endpoints");
332
372
  project
333
373
  .command("list")
334
374
  .description("List user projects")
335
375
  .action(async (...args) => {
336
- await runClientCommand(args, (client) => client.listProjects());
376
+ await runClientCommand(args, dependencies, (client) => client.listProjects());
337
377
  });
338
378
  project
339
379
  .command("get <projectId>")
340
380
  .description("Get a project by id")
341
381
  .action(async (...args) => {
342
382
  const [projectId] = args;
343
- await runClientCommand(args, (client) => client.getProject(projectId));
383
+ await runClientCommand(args, dependencies, (client) => client.getProject(projectId));
344
384
  });
345
385
  project
346
386
  .command("data <projectId>")
347
387
  .description("Get a project together with tasks and columns")
348
388
  .action(async (...args) => {
349
389
  const [projectId] = args;
350
- await runClientCommand(args, (client) => client.getProjectData(projectId));
390
+ await runClientCommand(args, dependencies, (client) => client.getProjectData(projectId));
351
391
  });
352
392
  withJsonBody(project
353
393
  .command("create")
@@ -365,11 +405,11 @@ function buildProjectCommands(root) {
365
405
  sortOrder: options.sortOrder,
366
406
  viewMode: options.viewMode,
367
407
  kind: options.kind,
368
- }));
408
+ }, dependencies));
369
409
  if (!payload.name) {
370
410
  throw new Error("Project creation requires name.");
371
411
  }
372
- await runClientCommand(args, (client) => client.createProject(payload));
412
+ await runClientCommand(args, dependencies, (client) => client.createProject(payload));
373
413
  });
374
414
  withJsonBody(project
375
415
  .command("update <projectId>")
@@ -388,18 +428,18 @@ function buildProjectCommands(root) {
388
428
  sortOrder: options.sortOrder,
389
429
  viewMode: options.viewMode,
390
430
  kind: options.kind,
391
- }));
392
- await runClientCommand(args, (client) => client.updateProject(projectId, payload));
431
+ }, dependencies));
432
+ await runClientCommand(args, dependencies, (client) => client.updateProject(projectId, payload));
393
433
  });
394
434
  project
395
435
  .command("delete <projectId>")
396
436
  .description("Delete a project")
397
437
  .action(async (...args) => {
398
438
  const [projectId] = args;
399
- await runClientCommand(args, (client) => client.deleteProject(projectId));
439
+ await runClientCommand(args, dependencies, (client) => client.deleteProject(projectId));
400
440
  });
401
441
  }
402
- function buildRequestCommand(root) {
442
+ function buildRequestCommand(root, dependencies) {
403
443
  withJsonBody(root
404
444
  .command("request <method> <path>")
405
445
  .description("Send a raw request to the configured API base URL, or to a full URL")
@@ -407,11 +447,13 @@ function buildRequestCommand(root) {
407
447
  const command = args.at(-1);
408
448
  const [method, path] = args;
409
449
  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 });
450
+ const config = await dependencies.resolveRuntimeConfig(runtimeOverrides(options, dependencies));
451
+ const client = dependencies.createClient(config);
452
+ const body = await dependencies.loadJsonValue(options.json, options.jsonFile);
453
+ const result = await client.requestRaw(method.toUpperCase(), path, body, {
454
+ auth: options.auth,
455
+ });
456
+ dependencies.printJson(result ?? { ok: true });
415
457
  });
416
458
  }
417
459
  function withJsonBody(command) {
@@ -419,10 +461,10 @@ function withJsonBody(command) {
419
461
  .option("--json <json>", "Inline JSON body")
420
462
  .option("--json-file <path>", "Path to a JSON body file");
421
463
  }
422
- function runtimeOverrides(options) {
464
+ function runtimeOverrides(options, dependencies) {
423
465
  return {
424
466
  configFile: options.configFile,
425
- service: options.service ? validateService(options.service) : undefined,
467
+ service: options.service ? dependencies.validateService(options.service) : undefined,
426
468
  apiBaseUrl: options.apiBaseUrl,
427
469
  authBaseUrl: options.authBaseUrl,
428
470
  accessToken: options.accessToken,
@@ -432,28 +474,28 @@ function runtimeOverrides(options) {
432
474
  scopes: options.scopes,
433
475
  };
434
476
  }
435
- async function runClientCommand(args, runner) {
477
+ async function runClientCommand(args, dependencies, runner) {
436
478
  const command = args.at(-1);
437
- const config = await resolveRuntimeConfig(runtimeOverrides(command.optsWithGlobals()));
438
- const client = new TickTickClient(config);
479
+ const config = await dependencies.resolveRuntimeConfig(runtimeOverrides(command.optsWithGlobals(), dependencies));
480
+ const client = dependencies.createClient(config);
439
481
  const result = await runner(client, config);
440
- printJson(result ?? { ok: true });
482
+ dependencies.printJson(result ?? { ok: true });
441
483
  }
442
- async function loadObjectPayload(options, flags) {
443
- const loaded = await loadJsonValue(options.json, options.jsonFile);
484
+ async function loadObjectPayload(options, flags, dependencies) {
485
+ const loaded = await dependencies.loadJsonValue(options.json, options.jsonFile);
444
486
  if (loaded === undefined) {
445
- return mergeDefined({}, flags);
487
+ return dependencies.mergeDefined({}, flags);
446
488
  }
447
489
  if (!isPlainObject(loaded)) {
448
490
  throw new Error("Expected a JSON object payload.");
449
491
  }
450
- return mergeDefined(loaded, flags);
492
+ return dependencies.mergeDefined(loaded, flags);
451
493
  }
452
494
  function isPlainObject(value) {
453
495
  return typeof value === "object" && value !== null && !Array.isArray(value);
454
496
  }
455
- async function persistConfig(runtime, accessToken) {
456
- const stored = await loadStoredConfig(runtime.configFile);
497
+ async function persistConfig(runtime, token, dependencies) {
498
+ const stored = await dependencies.loadStoredConfig(runtime.configFile);
457
499
  const next = {
458
500
  ...stored,
459
501
  service: runtime.service,
@@ -463,9 +505,9 @@ async function persistConfig(runtime, accessToken) {
463
505
  scopes: runtime.scopes,
464
506
  apiBaseUrl: runtime.apiBaseUrl,
465
507
  authBaseUrl: runtime.authBaseUrl,
466
- accessToken,
508
+ accessToken: token.access_token,
467
509
  };
468
- await saveStoredConfig(runtime.configFile, next);
510
+ await dependencies.saveStoredConfig(runtime.configFile, next);
469
511
  }
470
512
  function requireClientCredentials(config) {
471
513
  if (!config.clientId || !config.clientSecret) {
@@ -483,8 +525,9 @@ function collectString(value, previous = []) {
483
525
  function collectInteger(value, previous = []) {
484
526
  return [...previous, parseInteger(value)];
485
527
  }
486
- function handleError(error) {
487
- const message = error instanceof Error ? error.message : String(error);
488
- process.stderr.write(`${message}\n`);
489
- process.exitCode = 1;
528
+ function isDirectExecution() {
529
+ return Boolean(process.argv[1]) && import.meta.url === pathToFileURL(process.argv[1]).href;
530
+ }
531
+ if (isDirectExecution()) {
532
+ await main();
490
533
  }
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.2",
4
+ "description": "CLI wrapper for the TickTick Open API",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "dist",
@@ -9,15 +9,15 @@
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
17
  "check": "npm run build",
17
18
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
18
19
  "dev": "tsx src/cli.ts",
19
- "prepack": "npm run test",
20
- "test": "npm run build && node --import tsx --test src/test/*.test.ts"
20
+ "prepack": "npm run build"
21
21
  },
22
22
  "keywords": [
23
23
  "ticktick",