ticktick-cli 0.1.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/LICENSE +21 -0
- package/README.md +187 -0
- package/dist/auth.js +110 -0
- package/dist/cli.js +490 -0
- package/dist/client.js +101 -0
- package/dist/config.js +85 -0
- package/dist/types.js +1 -0
- package/dist/utils.js +69 -0
- package/package.json +44 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# TickTick CLI
|
|
2
|
+
|
|
3
|
+
A simple TypeScript CLI wrapper for the TickTick Open API documented at:
|
|
4
|
+
|
|
5
|
+
- https://developer.ticktick.com/
|
|
6
|
+
- https://developer.ticktick.com/docs#/openapi
|
|
7
|
+
|
|
8
|
+
This wrapper covers the documented OAuth flow plus every documented task and project endpoint.
|
|
9
|
+
|
|
10
|
+
## What it covers
|
|
11
|
+
|
|
12
|
+
- OAuth authorization code flow
|
|
13
|
+
- OAuth authorize URL generation
|
|
14
|
+
- Get, create, update, complete, delete, move, list-completed, and filter task endpoints
|
|
15
|
+
- List, get, create, update, delete, and get-data project endpoints
|
|
16
|
+
- Raw authenticated request passthrough
|
|
17
|
+
- Local config storage for client credentials and access tokens
|
|
18
|
+
- `ticktick` and `dida365` service profiles, with manual base URL overrides when needed
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install
|
|
24
|
+
npm run build
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Run locally with:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
node dist/cli.js --help
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Or install the built CLI globally from this directory:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install -g .
|
|
37
|
+
ticktick --help
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Configure
|
|
41
|
+
|
|
42
|
+
The CLI reads config in this order:
|
|
43
|
+
|
|
44
|
+
1. Command flags
|
|
45
|
+
2. Environment variables
|
|
46
|
+
3. Local config file
|
|
47
|
+
4. Built-in defaults
|
|
48
|
+
|
|
49
|
+
Useful environment variables:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
TICKTICK_SERVICE=ticktick
|
|
53
|
+
TICKTICK_CLIENT_ID=...
|
|
54
|
+
TICKTICK_CLIENT_SECRET=...
|
|
55
|
+
TICKTICK_REDIRECT_URI=http://127.0.0.1:18463/callback
|
|
56
|
+
TICKTICK_SCOPES="tasks:read tasks:write"
|
|
57
|
+
TICKTICK_ACCESS_TOKEN=...
|
|
58
|
+
TICKTICK_API_BASE_URL=https://api.ticktick.com
|
|
59
|
+
TICKTICK_AUTH_BASE_URL=https://ticktick.com
|
|
60
|
+
TICKTICK_CONFIG_FILE=/custom/path/config.json
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
You can also persist config values:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
ticktick config set clientId YOUR_CLIENT_ID
|
|
67
|
+
ticktick config set clientSecret YOUR_CLIENT_SECRET
|
|
68
|
+
ticktick config set redirectUri http://127.0.0.1:18463/callback
|
|
69
|
+
ticktick config show
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Auth
|
|
73
|
+
|
|
74
|
+
Interactive login:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
ticktick auth login
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
If you already have an authorization code:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
ticktick auth exchange YOUR_CODE
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Print the authorize URL without starting the callback server:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
ticktick auth url
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Check current auth state:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
ticktick auth status
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Clear the stored access token:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
ticktick auth logout
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Examples
|
|
105
|
+
|
|
106
|
+
List projects:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
ticktick project list
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Get project details:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
ticktick project get 6226ff9877acee87727f6bca
|
|
116
|
+
ticktick project data 6226ff9877acee87727f6bca
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Create a project:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
ticktick project create --name "Inbox" --color "#F18181" --view-mode list --kind TASK
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Create a task:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
ticktick task create --project-id 6226ff9877acee87727f6bca --title "Ship CLI"
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Update a task:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
ticktick task update 63b7bebb91c0a5474805fcd4 --project-id 6226ff9877acee87727f6bca --priority 3
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Move one task:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
ticktick task move \
|
|
141
|
+
--from-project-id 69a850ef1c20d2030e148fdd \
|
|
142
|
+
--to-project-id 69a850f41c20d2030e148fdf \
|
|
143
|
+
--task-id 69a850f8b9061f374d54a046
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Filter tasks using JSON:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
ticktick task filter --json '{
|
|
150
|
+
"projectIds": ["69a850f41c20d2030e148fdf"],
|
|
151
|
+
"startDate": "2026-03-01T00:58:20.000+0000",
|
|
152
|
+
"endDate": "2026-03-06T10:58:20.000+0000",
|
|
153
|
+
"priority": [0],
|
|
154
|
+
"tag": ["urgent"],
|
|
155
|
+
"status": [0]
|
|
156
|
+
}'
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Move multiple tasks from a file:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
ticktick task move --json-file ./moves.json
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Pipe JSON into a command:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
echo '{"name":"Planning","kind":"TASK"}' | ticktick project create
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Send a raw authenticated request:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
ticktick request GET /open/v1/project
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Send a raw request to a full URL without bearer auth:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
ticktick request POST https://httpbin.org/post --no-auth --json '{"hello":"world"}'
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Notes
|
|
184
|
+
|
|
185
|
+
- 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.
|
|
186
|
+
- The CLI writes config to a local JSON file under the OS-specific app config directory unless `--config-file` or `TICKTICK_CONFIG_FILE` is set.
|
|
187
|
+
- Access tokens and client secrets are stored as plain text in that config file. That keeps the wrapper simple, but it is not a secure keychain integration.
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
export function buildAuthorizationUrl(config, state = randomUUID()) {
|
|
7
|
+
const url = new URL("/oauth/authorize", config.authBaseUrl);
|
|
8
|
+
url.searchParams.set("scope", config.scopes);
|
|
9
|
+
url.searchParams.set("client_id", config.clientId ?? "");
|
|
10
|
+
url.searchParams.set("state", state);
|
|
11
|
+
url.searchParams.set("redirect_uri", config.redirectUri ?? "");
|
|
12
|
+
url.searchParams.set("response_type", "code");
|
|
13
|
+
return { url: url.toString(), state };
|
|
14
|
+
}
|
|
15
|
+
export async function exchangeAuthorizationCode(config, code, fetchImpl = fetch) {
|
|
16
|
+
if (!config.clientId || !config.clientSecret) {
|
|
17
|
+
throw new Error("Client credentials are required. Provide them with flags, env vars, or `ticktick config set`.");
|
|
18
|
+
}
|
|
19
|
+
const tokenUrl = new URL("/oauth/token", config.authBaseUrl);
|
|
20
|
+
const params = new URLSearchParams({
|
|
21
|
+
grant_type: "authorization_code",
|
|
22
|
+
code,
|
|
23
|
+
scope: config.scopes,
|
|
24
|
+
redirect_uri: config.redirectUri ?? "",
|
|
25
|
+
});
|
|
26
|
+
const response = await fetchImpl(tokenUrl, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: {
|
|
29
|
+
Authorization: `Basic ${Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64")}`,
|
|
30
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
31
|
+
},
|
|
32
|
+
body: params,
|
|
33
|
+
});
|
|
34
|
+
const raw = await response.text();
|
|
35
|
+
const parsed = raw.length > 0 ? JSON.parse(raw) : {};
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
throw new Error(`Token exchange failed with status ${response.status}: ${JSON.stringify(parsed)}`);
|
|
38
|
+
}
|
|
39
|
+
return parsed;
|
|
40
|
+
}
|
|
41
|
+
export function isLoopbackRedirect(redirectUri) {
|
|
42
|
+
if (!redirectUri) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
const parsed = new URL(redirectUri);
|
|
46
|
+
return (parsed.protocol === "http:" &&
|
|
47
|
+
["127.0.0.1", "localhost"].includes(parsed.hostname));
|
|
48
|
+
}
|
|
49
|
+
export async function waitForOAuthCode(redirectUri, expectedState, timeoutMs = 120_000) {
|
|
50
|
+
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 || "/";
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const timeout = setTimeout(() => {
|
|
56
|
+
server.close();
|
|
57
|
+
reject(new Error("Timed out waiting for the OAuth callback."));
|
|
58
|
+
}, timeoutMs);
|
|
59
|
+
const server = createServer((request, response) => {
|
|
60
|
+
const requestUrl = new URL(request.url ?? "/", redirect.origin);
|
|
61
|
+
if (requestUrl.pathname !== callbackPath) {
|
|
62
|
+
response.statusCode = 404;
|
|
63
|
+
response.end("Not Found");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const code = requestUrl.searchParams.get("code");
|
|
67
|
+
const state = requestUrl.searchParams.get("state");
|
|
68
|
+
if (!code) {
|
|
69
|
+
response.statusCode = 400;
|
|
70
|
+
response.end("Missing authorization code.");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (state !== expectedState) {
|
|
74
|
+
response.statusCode = 400;
|
|
75
|
+
response.end("State mismatch.");
|
|
76
|
+
clearTimeout(timeout);
|
|
77
|
+
server.close();
|
|
78
|
+
reject(new Error("OAuth state mismatch."));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
response.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
82
|
+
response.end("<html><body><h1>TickTick CLI</h1><p>Authorization complete. You can close this window.</p></body></html>");
|
|
83
|
+
clearTimeout(timeout);
|
|
84
|
+
server.close();
|
|
85
|
+
resolve(code);
|
|
86
|
+
});
|
|
87
|
+
server.on("error", (error) => {
|
|
88
|
+
clearTimeout(timeout);
|
|
89
|
+
reject(error);
|
|
90
|
+
});
|
|
91
|
+
server.listen(port, hostname);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
export async function openBrowser(url) {
|
|
95
|
+
try {
|
|
96
|
+
if (process.platform === "win32") {
|
|
97
|
+
await execFileAsync("cmd", ["/c", "start", "", url]);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (process.platform === "darwin") {
|
|
101
|
+
await execFileAsync("open", [url]);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
await execFileAsync("xdg-open", [url]);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
108
|
+
process.stderr.write(`Could not open a browser automatically: ${message}\n`);
|
|
109
|
+
}
|
|
110
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { buildAuthorizationUrl, exchangeAuthorizationCode, isLoopbackRedirect, openBrowser, waitForOAuthCode, } from "./auth.js";
|
|
4
|
+
import { TickTickClient } from "./client.js";
|
|
5
|
+
import { loadStoredConfig, resolveRuntimeConfig, saveStoredConfig, validateService, } from "./config.js";
|
|
6
|
+
import { loadJsonValue, maskSecret, mergeDefined, parseBoolean, parseInteger, printJson, } from "./utils.js";
|
|
7
|
+
const CONFIG_KEYS = new Set([
|
|
8
|
+
"service",
|
|
9
|
+
"clientId",
|
|
10
|
+
"clientSecret",
|
|
11
|
+
"redirectUri",
|
|
12
|
+
"scopes",
|
|
13
|
+
"accessToken",
|
|
14
|
+
"apiBaseUrl",
|
|
15
|
+
"authBaseUrl",
|
|
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) {
|
|
37
|
+
const auth = root.command("auth").description("OAuth and token management");
|
|
38
|
+
auth
|
|
39
|
+
.command("url")
|
|
40
|
+
.description("Print the OAuth authorize URL without opening a browser")
|
|
41
|
+
.option("--state <value>", "Override the OAuth state value")
|
|
42
|
+
.action(async (...args) => {
|
|
43
|
+
const command = args.at(-1);
|
|
44
|
+
const options = command.optsWithGlobals();
|
|
45
|
+
const config = await resolveRuntimeConfig(runtimeOverrides(options));
|
|
46
|
+
requireClientId(config);
|
|
47
|
+
const result = buildAuthorizationUrl(config, options.state);
|
|
48
|
+
printJson(result);
|
|
49
|
+
});
|
|
50
|
+
auth
|
|
51
|
+
.command("login")
|
|
52
|
+
.description("Open the OAuth flow, exchange the code, and store the access token")
|
|
53
|
+
.option("--timeout-ms <number>", "Timeout while waiting for the callback", parseInteger, 120000)
|
|
54
|
+
.action(async (...args) => {
|
|
55
|
+
const command = args.at(-1);
|
|
56
|
+
const options = command.optsWithGlobals();
|
|
57
|
+
const config = await resolveRuntimeConfig(runtimeOverrides(options));
|
|
58
|
+
requireClientCredentials(config);
|
|
59
|
+
const { url, state } = buildAuthorizationUrl(config);
|
|
60
|
+
process.stderr.write(`Authorize URL:\n${url}\n`);
|
|
61
|
+
if (!isLoopbackRedirect(config.redirectUri)) {
|
|
62
|
+
printJson({
|
|
63
|
+
ok: false,
|
|
64
|
+
reason: "redirect_uri is not a local HTTP callback. Open the URL above and then run `ticktick auth exchange <code>`.",
|
|
65
|
+
});
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const codePromise = waitForOAuthCode(config.redirectUri, state, options.timeoutMs);
|
|
69
|
+
await openBrowser(url);
|
|
70
|
+
const code = await codePromise;
|
|
71
|
+
const token = await exchangeAuthorizationCode(config, code);
|
|
72
|
+
await persistConfig(config, token.access_token);
|
|
73
|
+
printJson(token);
|
|
74
|
+
});
|
|
75
|
+
auth
|
|
76
|
+
.command("exchange <code>")
|
|
77
|
+
.description("Exchange an authorization code for an access token")
|
|
78
|
+
.action(async (...args) => {
|
|
79
|
+
const command = args.at(-1);
|
|
80
|
+
const [code] = args;
|
|
81
|
+
const config = await resolveRuntimeConfig(runtimeOverrides(command.optsWithGlobals()));
|
|
82
|
+
requireClientCredentials(config);
|
|
83
|
+
const token = await exchangeAuthorizationCode(config, code);
|
|
84
|
+
await persistConfig(config, token.access_token);
|
|
85
|
+
printJson(token);
|
|
86
|
+
});
|
|
87
|
+
auth
|
|
88
|
+
.command("status")
|
|
89
|
+
.description("Show the resolved auth configuration")
|
|
90
|
+
.option("--show-secrets", "Include client secret and access token")
|
|
91
|
+
.action(async (...args) => {
|
|
92
|
+
const command = args.at(-1);
|
|
93
|
+
const options = command.optsWithGlobals();
|
|
94
|
+
const config = await resolveRuntimeConfig(runtimeOverrides(options));
|
|
95
|
+
printJson({
|
|
96
|
+
service: config.service,
|
|
97
|
+
configFile: config.configFile,
|
|
98
|
+
clientId: config.clientId,
|
|
99
|
+
clientSecret: options.showSecrets ? config.clientSecret : maskSecret(config.clientSecret),
|
|
100
|
+
redirectUri: config.redirectUri,
|
|
101
|
+
scopes: config.scopes,
|
|
102
|
+
accessToken: options.showSecrets ? config.accessToken : maskSecret(config.accessToken),
|
|
103
|
+
apiBaseUrl: config.apiBaseUrl,
|
|
104
|
+
authBaseUrl: config.authBaseUrl,
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
auth
|
|
108
|
+
.command("logout")
|
|
109
|
+
.description("Remove the stored access token from the config file")
|
|
110
|
+
.action(async (...args) => {
|
|
111
|
+
const command = args.at(-1);
|
|
112
|
+
const config = await resolveRuntimeConfig(runtimeOverrides(command.optsWithGlobals()));
|
|
113
|
+
const stored = await loadStoredConfig(config.configFile);
|
|
114
|
+
delete stored.accessToken;
|
|
115
|
+
await saveStoredConfig(config.configFile, stored);
|
|
116
|
+
printJson({ ok: true });
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
function buildConfigCommands(root) {
|
|
120
|
+
const config = root.command("config").description("Read and write local CLI config");
|
|
121
|
+
config
|
|
122
|
+
.command("show")
|
|
123
|
+
.description("Show the resolved configuration")
|
|
124
|
+
.option("--show-secrets", "Include stored secrets")
|
|
125
|
+
.action(async (...args) => {
|
|
126
|
+
const command = args.at(-1);
|
|
127
|
+
const options = command.optsWithGlobals();
|
|
128
|
+
const resolved = await resolveRuntimeConfig(runtimeOverrides(options));
|
|
129
|
+
printJson({
|
|
130
|
+
service: resolved.service,
|
|
131
|
+
configFile: resolved.configFile,
|
|
132
|
+
clientId: resolved.clientId,
|
|
133
|
+
clientSecret: options.showSecrets ? resolved.clientSecret : maskSecret(resolved.clientSecret),
|
|
134
|
+
redirectUri: resolved.redirectUri,
|
|
135
|
+
scopes: resolved.scopes,
|
|
136
|
+
accessToken: options.showSecrets ? resolved.accessToken : maskSecret(resolved.accessToken),
|
|
137
|
+
apiBaseUrl: resolved.apiBaseUrl,
|
|
138
|
+
authBaseUrl: resolved.authBaseUrl,
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
config
|
|
142
|
+
.command("set <key> <value>")
|
|
143
|
+
.description("Set a config value in the local config file")
|
|
144
|
+
.action(async (...args) => {
|
|
145
|
+
const command = args.at(-1);
|
|
146
|
+
const [key, value] = args;
|
|
147
|
+
const runtime = await resolveRuntimeConfig(runtimeOverrides(command.optsWithGlobals()));
|
|
148
|
+
if (!CONFIG_KEYS.has(key)) {
|
|
149
|
+
throw new Error(`Unsupported config key "${key}". Allowed keys: ${Array.from(CONFIG_KEYS).join(", ")}`);
|
|
150
|
+
}
|
|
151
|
+
const stored = await loadStoredConfig(runtime.configFile);
|
|
152
|
+
const normalizedValue = key === "service" ? validateService(value) : value;
|
|
153
|
+
const next = { ...stored, [key]: normalizedValue };
|
|
154
|
+
await saveStoredConfig(runtime.configFile, next);
|
|
155
|
+
printJson(next);
|
|
156
|
+
});
|
|
157
|
+
config
|
|
158
|
+
.command("unset <key>")
|
|
159
|
+
.description("Remove a config value from the local config file")
|
|
160
|
+
.action(async (...args) => {
|
|
161
|
+
const command = args.at(-1);
|
|
162
|
+
const [key] = args;
|
|
163
|
+
const runtime = await resolveRuntimeConfig(runtimeOverrides(command.optsWithGlobals()));
|
|
164
|
+
if (!CONFIG_KEYS.has(key)) {
|
|
165
|
+
throw new Error(`Unsupported config key "${key}". Allowed keys: ${Array.from(CONFIG_KEYS).join(", ")}`);
|
|
166
|
+
}
|
|
167
|
+
const stored = await loadStoredConfig(runtime.configFile);
|
|
168
|
+
delete stored[key];
|
|
169
|
+
await saveStoredConfig(runtime.configFile, stored);
|
|
170
|
+
printJson(stored);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
function buildTaskCommands(root) {
|
|
174
|
+
const task = root.command("task").description("Task endpoints");
|
|
175
|
+
task
|
|
176
|
+
.command("get <projectId> <taskId>")
|
|
177
|
+
.description("Get a task by project id and task id")
|
|
178
|
+
.action(async (...args) => {
|
|
179
|
+
const [projectId, taskId] = args;
|
|
180
|
+
await runClientCommand(args, (client) => client.getTask(projectId, taskId));
|
|
181
|
+
});
|
|
182
|
+
withJsonBody(task
|
|
183
|
+
.command("create")
|
|
184
|
+
.description("Create a task")
|
|
185
|
+
.option("--project-id <id>", "Project id")
|
|
186
|
+
.option("--title <title>", "Task title")
|
|
187
|
+
.option("--content <content>", "Task content")
|
|
188
|
+
.option("--desc <desc>", "Task description")
|
|
189
|
+
.option("--start-date <date>", "Start date in yyyy-MM-dd'T'HH:mm:ssZ format")
|
|
190
|
+
.option("--due-date <date>", "Due date in yyyy-MM-dd'T'HH:mm:ssZ format")
|
|
191
|
+
.option("--time-zone <tz>", "Time zone")
|
|
192
|
+
.option("--repeat-flag <rrule>", "Recurring rule")
|
|
193
|
+
.option("--priority <number>", "Priority", parseInteger)
|
|
194
|
+
.option("--sort-order <number>", "Sort order", parseInteger)
|
|
195
|
+
.option("--all-day <boolean>", "All day", parseBoolean)).action(async (...args) => {
|
|
196
|
+
const command = args.at(-1);
|
|
197
|
+
const options = command.optsWithGlobals();
|
|
198
|
+
const payload = (await loadObjectPayload(options, {
|
|
199
|
+
projectId: options.projectId,
|
|
200
|
+
title: options.title,
|
|
201
|
+
content: options.content,
|
|
202
|
+
desc: options.desc,
|
|
203
|
+
startDate: options.startDate,
|
|
204
|
+
dueDate: options.dueDate,
|
|
205
|
+
timeZone: options.timeZone,
|
|
206
|
+
repeatFlag: options.repeatFlag,
|
|
207
|
+
priority: options.priority,
|
|
208
|
+
sortOrder: options.sortOrder,
|
|
209
|
+
isAllDay: options.allDay,
|
|
210
|
+
}));
|
|
211
|
+
if (!payload.projectId || !payload.title) {
|
|
212
|
+
throw new Error("Task creation requires both projectId and title.");
|
|
213
|
+
}
|
|
214
|
+
await runClientCommand(args, (client) => client.createTask(payload));
|
|
215
|
+
});
|
|
216
|
+
withJsonBody(task
|
|
217
|
+
.command("update <taskId>")
|
|
218
|
+
.description("Update a task")
|
|
219
|
+
.option("--project-id <id>", "Project id")
|
|
220
|
+
.option("--title <title>", "Task title")
|
|
221
|
+
.option("--content <content>", "Task content")
|
|
222
|
+
.option("--desc <desc>", "Task description")
|
|
223
|
+
.option("--start-date <date>", "Start date in yyyy-MM-dd'T'HH:mm:ssZ format")
|
|
224
|
+
.option("--due-date <date>", "Due date in yyyy-MM-dd'T'HH:mm:ssZ format")
|
|
225
|
+
.option("--time-zone <tz>", "Time zone")
|
|
226
|
+
.option("--repeat-flag <rrule>", "Recurring rule")
|
|
227
|
+
.option("--priority <number>", "Priority", parseInteger)
|
|
228
|
+
.option("--sort-order <number>", "Sort order", parseInteger)
|
|
229
|
+
.option("--all-day <boolean>", "All day", parseBoolean)).action(async (...args) => {
|
|
230
|
+
const command = args.at(-1);
|
|
231
|
+
const [taskId] = args;
|
|
232
|
+
const options = command.optsWithGlobals();
|
|
233
|
+
const payload = (await loadObjectPayload(options, {
|
|
234
|
+
id: taskId,
|
|
235
|
+
projectId: options.projectId,
|
|
236
|
+
title: options.title,
|
|
237
|
+
content: options.content,
|
|
238
|
+
desc: options.desc,
|
|
239
|
+
startDate: options.startDate,
|
|
240
|
+
dueDate: options.dueDate,
|
|
241
|
+
timeZone: options.timeZone,
|
|
242
|
+
repeatFlag: options.repeatFlag,
|
|
243
|
+
priority: options.priority,
|
|
244
|
+
sortOrder: options.sortOrder,
|
|
245
|
+
isAllDay: options.allDay,
|
|
246
|
+
}));
|
|
247
|
+
payload.id = taskId;
|
|
248
|
+
if (!payload.projectId) {
|
|
249
|
+
throw new Error("Task update requires projectId.");
|
|
250
|
+
}
|
|
251
|
+
await runClientCommand(args, (client) => client.updateTask(taskId, payload));
|
|
252
|
+
});
|
|
253
|
+
task
|
|
254
|
+
.command("complete <projectId> <taskId>")
|
|
255
|
+
.description("Complete a task")
|
|
256
|
+
.action(async (...args) => {
|
|
257
|
+
const [projectId, taskId] = args;
|
|
258
|
+
await runClientCommand(args, (client) => client.completeTask(projectId, taskId));
|
|
259
|
+
});
|
|
260
|
+
task
|
|
261
|
+
.command("delete <projectId> <taskId>")
|
|
262
|
+
.description("Delete a task")
|
|
263
|
+
.action(async (...args) => {
|
|
264
|
+
const [projectId, taskId] = args;
|
|
265
|
+
await runClientCommand(args, (client) => client.deleteTask(projectId, taskId));
|
|
266
|
+
});
|
|
267
|
+
withJsonBody(task
|
|
268
|
+
.command("move")
|
|
269
|
+
.description("Move one or more tasks between projects")
|
|
270
|
+
.option("--from-project-id <id>", "Source project id")
|
|
271
|
+
.option("--to-project-id <id>", "Destination project id")
|
|
272
|
+
.option("--task-id <id>", "Task id")).action(async (...args) => {
|
|
273
|
+
const command = args.at(-1);
|
|
274
|
+
const options = command.optsWithGlobals();
|
|
275
|
+
let payload = (await loadJsonValue(options.json, options.jsonFile));
|
|
276
|
+
if (!payload) {
|
|
277
|
+
if (!options.fromProjectId || !options.toProjectId || !options.taskId) {
|
|
278
|
+
throw new Error("Provide --json/--json-file or all of --from-project-id, --to-project-id, and --task-id.");
|
|
279
|
+
}
|
|
280
|
+
payload = [
|
|
281
|
+
{
|
|
282
|
+
fromProjectId: options.fromProjectId,
|
|
283
|
+
toProjectId: options.toProjectId,
|
|
284
|
+
taskId: options.taskId,
|
|
285
|
+
},
|
|
286
|
+
];
|
|
287
|
+
}
|
|
288
|
+
if (!Array.isArray(payload)) {
|
|
289
|
+
throw new Error("Move payload must be a JSON array.");
|
|
290
|
+
}
|
|
291
|
+
await runClientCommand(args, (client) => client.moveTasks(payload));
|
|
292
|
+
});
|
|
293
|
+
withJsonBody(task
|
|
294
|
+
.command("completed")
|
|
295
|
+
.description("List completed tasks")
|
|
296
|
+
.option("--project-id <id>", "Project id", collectString, [])
|
|
297
|
+
.option("--start-date <date>", "Start of completedTime range")
|
|
298
|
+
.option("--end-date <date>", "End of completedTime range")).action(async (...args) => {
|
|
299
|
+
const command = args.at(-1);
|
|
300
|
+
const options = command.optsWithGlobals();
|
|
301
|
+
const payload = (await loadObjectPayload(options, {
|
|
302
|
+
projectIds: options.projectId && options.projectId.length > 0 ? options.projectId : undefined,
|
|
303
|
+
startDate: options.startDate,
|
|
304
|
+
endDate: options.endDate,
|
|
305
|
+
}));
|
|
306
|
+
await runClientCommand(args, (client) => client.listCompletedTasks(payload));
|
|
307
|
+
});
|
|
308
|
+
withJsonBody(task
|
|
309
|
+
.command("filter")
|
|
310
|
+
.description("Filter tasks")
|
|
311
|
+
.option("--project-id <id>", "Project id", collectString, [])
|
|
312
|
+
.option("--start-date <date>", "Start date lower bound")
|
|
313
|
+
.option("--end-date <date>", "Start date upper bound")
|
|
314
|
+
.option("--priority <number>", "Priority filter", collectInteger, [])
|
|
315
|
+
.option("--tag <tag>", "Tag filter", collectString, [])
|
|
316
|
+
.option("--status <number>", "Status filter", collectInteger, [])).action(async (...args) => {
|
|
317
|
+
const command = args.at(-1);
|
|
318
|
+
const options = command.optsWithGlobals();
|
|
319
|
+
const payload = (await loadObjectPayload(options, {
|
|
320
|
+
projectIds: options.projectId && options.projectId.length > 0 ? options.projectId : undefined,
|
|
321
|
+
startDate: options.startDate,
|
|
322
|
+
endDate: options.endDate,
|
|
323
|
+
priority: options.priority && options.priority.length > 0 ? options.priority : undefined,
|
|
324
|
+
tag: options.tag && options.tag.length > 0 ? options.tag : undefined,
|
|
325
|
+
status: options.status && options.status.length > 0 ? options.status : undefined,
|
|
326
|
+
}));
|
|
327
|
+
await runClientCommand(args, (client) => client.filterTasks(payload));
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
function buildProjectCommands(root) {
|
|
331
|
+
const project = root.command("project").description("Project endpoints");
|
|
332
|
+
project
|
|
333
|
+
.command("list")
|
|
334
|
+
.description("List user projects")
|
|
335
|
+
.action(async (...args) => {
|
|
336
|
+
await runClientCommand(args, (client) => client.listProjects());
|
|
337
|
+
});
|
|
338
|
+
project
|
|
339
|
+
.command("get <projectId>")
|
|
340
|
+
.description("Get a project by id")
|
|
341
|
+
.action(async (...args) => {
|
|
342
|
+
const [projectId] = args;
|
|
343
|
+
await runClientCommand(args, (client) => client.getProject(projectId));
|
|
344
|
+
});
|
|
345
|
+
project
|
|
346
|
+
.command("data <projectId>")
|
|
347
|
+
.description("Get a project together with tasks and columns")
|
|
348
|
+
.action(async (...args) => {
|
|
349
|
+
const [projectId] = args;
|
|
350
|
+
await runClientCommand(args, (client) => client.getProjectData(projectId));
|
|
351
|
+
});
|
|
352
|
+
withJsonBody(project
|
|
353
|
+
.command("create")
|
|
354
|
+
.description("Create a project")
|
|
355
|
+
.option("--name <name>", "Project name")
|
|
356
|
+
.option("--color <color>", 'Project color, for example "#F18181"')
|
|
357
|
+
.option("--sort-order <number>", "Sort order", parseInteger)
|
|
358
|
+
.option("--view-mode <mode>", 'View mode: "list", "kanban", or "timeline"')
|
|
359
|
+
.option("--kind <kind>", 'Kind: "TASK" or "NOTE"')).action(async (...args) => {
|
|
360
|
+
const command = args.at(-1);
|
|
361
|
+
const options = command.optsWithGlobals();
|
|
362
|
+
const payload = (await loadObjectPayload(options, {
|
|
363
|
+
name: options.name,
|
|
364
|
+
color: options.color,
|
|
365
|
+
sortOrder: options.sortOrder,
|
|
366
|
+
viewMode: options.viewMode,
|
|
367
|
+
kind: options.kind,
|
|
368
|
+
}));
|
|
369
|
+
if (!payload.name) {
|
|
370
|
+
throw new Error("Project creation requires name.");
|
|
371
|
+
}
|
|
372
|
+
await runClientCommand(args, (client) => client.createProject(payload));
|
|
373
|
+
});
|
|
374
|
+
withJsonBody(project
|
|
375
|
+
.command("update <projectId>")
|
|
376
|
+
.description("Update a project")
|
|
377
|
+
.option("--name <name>", "Project name")
|
|
378
|
+
.option("--color <color>", 'Project color, for example "#F18181"')
|
|
379
|
+
.option("--sort-order <number>", "Sort order", parseInteger)
|
|
380
|
+
.option("--view-mode <mode>", 'View mode: "list", "kanban", or "timeline"')
|
|
381
|
+
.option("--kind <kind>", 'Kind: "TASK" or "NOTE"')).action(async (...args) => {
|
|
382
|
+
const command = args.at(-1);
|
|
383
|
+
const [projectId] = args;
|
|
384
|
+
const options = command.optsWithGlobals();
|
|
385
|
+
const payload = (await loadObjectPayload(options, {
|
|
386
|
+
name: options.name,
|
|
387
|
+
color: options.color,
|
|
388
|
+
sortOrder: options.sortOrder,
|
|
389
|
+
viewMode: options.viewMode,
|
|
390
|
+
kind: options.kind,
|
|
391
|
+
}));
|
|
392
|
+
await runClientCommand(args, (client) => client.updateProject(projectId, payload));
|
|
393
|
+
});
|
|
394
|
+
project
|
|
395
|
+
.command("delete <projectId>")
|
|
396
|
+
.description("Delete a project")
|
|
397
|
+
.action(async (...args) => {
|
|
398
|
+
const [projectId] = args;
|
|
399
|
+
await runClientCommand(args, (client) => client.deleteProject(projectId));
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
function buildRequestCommand(root) {
|
|
403
|
+
withJsonBody(root
|
|
404
|
+
.command("request <method> <path>")
|
|
405
|
+
.description("Send a raw request to the configured API base URL, or to a full URL")
|
|
406
|
+
.option("--no-auth", "Skip bearer auth on this request")).action(async (...args) => {
|
|
407
|
+
const command = args.at(-1);
|
|
408
|
+
const [method, path] = args;
|
|
409
|
+
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 });
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
function withJsonBody(command) {
|
|
418
|
+
return command
|
|
419
|
+
.option("--json <json>", "Inline JSON body")
|
|
420
|
+
.option("--json-file <path>", "Path to a JSON body file");
|
|
421
|
+
}
|
|
422
|
+
function runtimeOverrides(options) {
|
|
423
|
+
return {
|
|
424
|
+
configFile: options.configFile,
|
|
425
|
+
service: options.service ? validateService(options.service) : undefined,
|
|
426
|
+
apiBaseUrl: options.apiBaseUrl,
|
|
427
|
+
authBaseUrl: options.authBaseUrl,
|
|
428
|
+
accessToken: options.accessToken,
|
|
429
|
+
clientId: options.clientId,
|
|
430
|
+
clientSecret: options.clientSecret,
|
|
431
|
+
redirectUri: options.redirectUri,
|
|
432
|
+
scopes: options.scopes,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
async function runClientCommand(args, runner) {
|
|
436
|
+
const command = args.at(-1);
|
|
437
|
+
const config = await resolveRuntimeConfig(runtimeOverrides(command.optsWithGlobals()));
|
|
438
|
+
const client = new TickTickClient(config);
|
|
439
|
+
const result = await runner(client, config);
|
|
440
|
+
printJson(result ?? { ok: true });
|
|
441
|
+
}
|
|
442
|
+
async function loadObjectPayload(options, flags) {
|
|
443
|
+
const loaded = await loadJsonValue(options.json, options.jsonFile);
|
|
444
|
+
if (loaded === undefined) {
|
|
445
|
+
return mergeDefined({}, flags);
|
|
446
|
+
}
|
|
447
|
+
if (!isPlainObject(loaded)) {
|
|
448
|
+
throw new Error("Expected a JSON object payload.");
|
|
449
|
+
}
|
|
450
|
+
return mergeDefined(loaded, flags);
|
|
451
|
+
}
|
|
452
|
+
function isPlainObject(value) {
|
|
453
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
454
|
+
}
|
|
455
|
+
async function persistConfig(runtime, accessToken) {
|
|
456
|
+
const stored = await loadStoredConfig(runtime.configFile);
|
|
457
|
+
const next = {
|
|
458
|
+
...stored,
|
|
459
|
+
service: runtime.service,
|
|
460
|
+
clientId: runtime.clientId,
|
|
461
|
+
clientSecret: runtime.clientSecret,
|
|
462
|
+
redirectUri: runtime.redirectUri,
|
|
463
|
+
scopes: runtime.scopes,
|
|
464
|
+
apiBaseUrl: runtime.apiBaseUrl,
|
|
465
|
+
authBaseUrl: runtime.authBaseUrl,
|
|
466
|
+
accessToken,
|
|
467
|
+
};
|
|
468
|
+
await saveStoredConfig(runtime.configFile, next);
|
|
469
|
+
}
|
|
470
|
+
function requireClientCredentials(config) {
|
|
471
|
+
if (!config.clientId || !config.clientSecret) {
|
|
472
|
+
throw new Error("Client credentials are required. Set TICKTICK_CLIENT_ID and TICKTICK_CLIENT_SECRET or use `ticktick config set`.");
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
function requireClientId(config) {
|
|
476
|
+
if (!config.clientId) {
|
|
477
|
+
throw new Error("Client id is required. Set TICKTICK_CLIENT_ID or use `ticktick config set clientId ...`.");
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
function collectString(value, previous = []) {
|
|
481
|
+
return [...previous, value];
|
|
482
|
+
}
|
|
483
|
+
function collectInteger(value, previous = []) {
|
|
484
|
+
return [...previous, parseInteger(value)];
|
|
485
|
+
}
|
|
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/client.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
export class TickTickApiError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
payload;
|
|
4
|
+
constructor(status, payload) {
|
|
5
|
+
const summary = typeof payload === "string"
|
|
6
|
+
? payload
|
|
7
|
+
: payload
|
|
8
|
+
? JSON.stringify(payload)
|
|
9
|
+
: "No response body";
|
|
10
|
+
super(`TickTick API request failed with status ${status}: ${summary}`);
|
|
11
|
+
this.name = "TickTickApiError";
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.payload = payload;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export class TickTickClient {
|
|
17
|
+
config;
|
|
18
|
+
fetchImpl;
|
|
19
|
+
constructor(config, fetchImpl = fetch) {
|
|
20
|
+
this.config = config;
|
|
21
|
+
this.fetchImpl = fetchImpl;
|
|
22
|
+
}
|
|
23
|
+
async requestRaw(method, pathOrUrl, body, options = {}) {
|
|
24
|
+
const requiresAuth = options.auth ?? true;
|
|
25
|
+
if (requiresAuth && !this.config.accessToken) {
|
|
26
|
+
throw new Error("No access token available. Run `ticktick auth login` or provide TICKTICK_ACCESS_TOKEN.");
|
|
27
|
+
}
|
|
28
|
+
const url = /^https?:\/\//.test(pathOrUrl)
|
|
29
|
+
? pathOrUrl
|
|
30
|
+
: `${this.config.apiBaseUrl}${pathOrUrl}`;
|
|
31
|
+
const response = await this.fetchImpl(url, {
|
|
32
|
+
method,
|
|
33
|
+
headers: {
|
|
34
|
+
...(requiresAuth
|
|
35
|
+
? { Authorization: `Bearer ${this.config.accessToken}` }
|
|
36
|
+
: {}),
|
|
37
|
+
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
|
38
|
+
},
|
|
39
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
40
|
+
});
|
|
41
|
+
if (response.status === 204) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
const raw = await response.text();
|
|
45
|
+
const parsed = raw.length > 0 ? tryParseJson(raw) : undefined;
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
throw new TickTickApiError(response.status, parsed ?? raw);
|
|
48
|
+
}
|
|
49
|
+
return parsed;
|
|
50
|
+
}
|
|
51
|
+
getTask(projectId, taskId) {
|
|
52
|
+
return this.requestRaw("GET", `/open/v1/project/${projectId}/task/${taskId}`);
|
|
53
|
+
}
|
|
54
|
+
createTask(payload) {
|
|
55
|
+
return this.requestRaw("POST", "/open/v1/task", payload);
|
|
56
|
+
}
|
|
57
|
+
updateTask(taskId, payload) {
|
|
58
|
+
return this.requestRaw("POST", `/open/v1/task/${taskId}`, payload);
|
|
59
|
+
}
|
|
60
|
+
completeTask(projectId, taskId) {
|
|
61
|
+
return this.requestRaw("POST", `/open/v1/project/${projectId}/task/${taskId}/complete`);
|
|
62
|
+
}
|
|
63
|
+
deleteTask(projectId, taskId) {
|
|
64
|
+
return this.requestRaw("DELETE", `/open/v1/project/${projectId}/task/${taskId}`);
|
|
65
|
+
}
|
|
66
|
+
moveTasks(payload) {
|
|
67
|
+
return this.requestRaw("POST", "/open/v1/task/move", payload);
|
|
68
|
+
}
|
|
69
|
+
listCompletedTasks(payload) {
|
|
70
|
+
return this.requestRaw("POST", "/open/v1/task/completed", payload);
|
|
71
|
+
}
|
|
72
|
+
filterTasks(payload) {
|
|
73
|
+
return this.requestRaw("POST", "/open/v1/task/filter", payload);
|
|
74
|
+
}
|
|
75
|
+
listProjects() {
|
|
76
|
+
return this.requestRaw("GET", "/open/v1/project");
|
|
77
|
+
}
|
|
78
|
+
getProject(projectId) {
|
|
79
|
+
return this.requestRaw("GET", `/open/v1/project/${projectId}`);
|
|
80
|
+
}
|
|
81
|
+
getProjectData(projectId) {
|
|
82
|
+
return this.requestRaw("GET", `/open/v1/project/${projectId}/data`);
|
|
83
|
+
}
|
|
84
|
+
createProject(payload) {
|
|
85
|
+
return this.requestRaw("POST", "/open/v1/project", payload);
|
|
86
|
+
}
|
|
87
|
+
updateProject(projectId, payload) {
|
|
88
|
+
return this.requestRaw("POST", `/open/v1/project/${projectId}`, payload);
|
|
89
|
+
}
|
|
90
|
+
deleteProject(projectId) {
|
|
91
|
+
return this.requestRaw("DELETE", `/open/v1/project/${projectId}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function tryParseJson(value) {
|
|
95
|
+
try {
|
|
96
|
+
return JSON.parse(value);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const DEFAULT_SCOPES = "tasks:read tasks:write";
|
|
5
|
+
const DEFAULT_SERVICE = "ticktick";
|
|
6
|
+
const SERVICE_DEFAULTS = {
|
|
7
|
+
ticktick: {
|
|
8
|
+
apiBaseUrl: "https://api.ticktick.com",
|
|
9
|
+
authBaseUrl: "https://ticktick.com",
|
|
10
|
+
},
|
|
11
|
+
dida365: {
|
|
12
|
+
apiBaseUrl: "https://api.dida365.com",
|
|
13
|
+
authBaseUrl: "https://dida365.com",
|
|
14
|
+
},
|
|
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");
|
|
20
|
+
}
|
|
21
|
+
if (process.platform === "darwin") {
|
|
22
|
+
return path.join(os.homedir(), "Library", "Application Support", "ticktick-cli", "config.json");
|
|
23
|
+
}
|
|
24
|
+
return path.join(os.homedir(), ".config", "ticktick-cli", "config.json");
|
|
25
|
+
}
|
|
26
|
+
export async function loadStoredConfig(configFile) {
|
|
27
|
+
try {
|
|
28
|
+
const raw = await readFile(configFile, "utf8");
|
|
29
|
+
return JSON.parse(raw);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error.code === "ENOENT") {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function saveStoredConfig(configFile, config) {
|
|
39
|
+
await mkdir(path.dirname(configFile), { recursive: true });
|
|
40
|
+
await writeFile(configFile, JSON.stringify(config, null, 2), "utf8");
|
|
41
|
+
}
|
|
42
|
+
export function validateService(service) {
|
|
43
|
+
if (service === "ticktick" || service === "dida365") {
|
|
44
|
+
return service;
|
|
45
|
+
}
|
|
46
|
+
throw new Error(`Unsupported service "${service}". Use "ticktick" or "dida365".`);
|
|
47
|
+
}
|
|
48
|
+
function envConfig(env) {
|
|
49
|
+
return {
|
|
50
|
+
service: env.TICKTICK_SERVICE
|
|
51
|
+
? validateService(env.TICKTICK_SERVICE)
|
|
52
|
+
: undefined,
|
|
53
|
+
clientId: env.TICKTICK_CLIENT_ID,
|
|
54
|
+
clientSecret: env.TICKTICK_CLIENT_SECRET,
|
|
55
|
+
redirectUri: env.TICKTICK_REDIRECT_URI,
|
|
56
|
+
scopes: env.TICKTICK_SCOPES,
|
|
57
|
+
accessToken: env.TICKTICK_ACCESS_TOKEN,
|
|
58
|
+
apiBaseUrl: env.TICKTICK_API_BASE_URL,
|
|
59
|
+
authBaseUrl: env.TICKTICK_AUTH_BASE_URL,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export async function resolveRuntimeConfig(overrides = {}, env = process.env) {
|
|
63
|
+
const configFile = overrides.configFile ?? env.TICKTICK_CONFIG_FILE ?? defaultConfigFilePath();
|
|
64
|
+
const stored = await loadStoredConfig(configFile);
|
|
65
|
+
const fromEnv = envConfig(env);
|
|
66
|
+
const service = overrides.service ?? fromEnv.service ?? stored.service ?? DEFAULT_SERVICE;
|
|
67
|
+
const defaults = SERVICE_DEFAULTS[service];
|
|
68
|
+
return {
|
|
69
|
+
service,
|
|
70
|
+
configFile,
|
|
71
|
+
clientId: overrides.clientId ?? fromEnv.clientId ?? stored.clientId,
|
|
72
|
+
clientSecret: overrides.clientSecret ?? fromEnv.clientSecret ?? stored.clientSecret,
|
|
73
|
+
redirectUri: overrides.redirectUri ??
|
|
74
|
+
fromEnv.redirectUri ??
|
|
75
|
+
stored.redirectUri ??
|
|
76
|
+
"http://127.0.0.1:18463/callback",
|
|
77
|
+
scopes: overrides.scopes ?? fromEnv.scopes ?? stored.scopes ?? DEFAULT_SCOPES,
|
|
78
|
+
accessToken: overrides.accessToken ?? fromEnv.accessToken ?? stored.accessToken,
|
|
79
|
+
apiBaseUrl: overrides.apiBaseUrl ?? fromEnv.apiBaseUrl ?? stored.apiBaseUrl ?? defaults.apiBaseUrl,
|
|
80
|
+
authBaseUrl: overrides.authBaseUrl ??
|
|
81
|
+
fromEnv.authBaseUrl ??
|
|
82
|
+
stored.authBaseUrl ??
|
|
83
|
+
defaults.authBaseUrl,
|
|
84
|
+
};
|
|
85
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
export function mergeDefined(base, patch) {
|
|
3
|
+
const next = { ...base };
|
|
4
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
5
|
+
if (value !== undefined) {
|
|
6
|
+
next[key] = value;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
return next;
|
|
10
|
+
}
|
|
11
|
+
export function parseBoolean(value) {
|
|
12
|
+
const normalized = value.trim().toLowerCase();
|
|
13
|
+
if (["true", "1", "yes", "y"].includes(normalized)) {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
if (["false", "0", "no", "n"].includes(normalized)) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
throw new Error(`Expected a boolean value, received "${value}".`);
|
|
20
|
+
}
|
|
21
|
+
export function parseInteger(value) {
|
|
22
|
+
const parsed = Number.parseInt(value, 10);
|
|
23
|
+
if (Number.isNaN(parsed)) {
|
|
24
|
+
throw new Error(`Expected an integer value, received "${value}".`);
|
|
25
|
+
}
|
|
26
|
+
return parsed;
|
|
27
|
+
}
|
|
28
|
+
export async function maybeReadStdin() {
|
|
29
|
+
if (process.stdin.isTTY) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
const chunks = [];
|
|
33
|
+
for await (const chunk of process.stdin) {
|
|
34
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
35
|
+
}
|
|
36
|
+
const text = Buffer.concat(chunks).toString("utf8").trim();
|
|
37
|
+
return text.length > 0 ? text : undefined;
|
|
38
|
+
}
|
|
39
|
+
export async function loadJsonValue(inlineJson, jsonFile) {
|
|
40
|
+
let raw = inlineJson;
|
|
41
|
+
if (jsonFile) {
|
|
42
|
+
raw = await readFile(jsonFile, "utf8");
|
|
43
|
+
}
|
|
44
|
+
if (!raw) {
|
|
45
|
+
raw = await maybeReadStdin();
|
|
46
|
+
}
|
|
47
|
+
if (!raw) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(raw);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
55
|
+
throw new Error(`Failed to parse JSON input: ${message}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export function printJson(value) {
|
|
59
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
60
|
+
}
|
|
61
|
+
export function maskSecret(value) {
|
|
62
|
+
if (!value) {
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
if (value.length <= 6) {
|
|
66
|
+
return "*".repeat(value.length);
|
|
67
|
+
}
|
|
68
|
+
return `${value.slice(0, 3)}***${value.slice(-3)}`;
|
|
69
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ticktick-cli",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Simple CLI wrapper for the TickTick Open API",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist",
|
|
8
|
+
"README.md",
|
|
9
|
+
"LICENSE"
|
|
10
|
+
],
|
|
11
|
+
"bin": {
|
|
12
|
+
"ticktick": "dist/cli.js"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "npm run clean && tsc -p tsconfig.json",
|
|
16
|
+
"check": "npm run build",
|
|
17
|
+
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
18
|
+
"dev": "tsx src/cli.ts",
|
|
19
|
+
"prepack": "npm run test",
|
|
20
|
+
"test": "npm run build && node --import tsx --test src/test/*.test.ts"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"ticktick",
|
|
24
|
+
"cli",
|
|
25
|
+
"oauth2",
|
|
26
|
+
"tasks",
|
|
27
|
+
"projects"
|
|
28
|
+
],
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=18"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"commander": "^14.0.1"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^24.6.0",
|
|
41
|
+
"tsx": "^4.20.5",
|
|
42
|
+
"typescript": "^5.9.3"
|
|
43
|
+
}
|
|
44
|
+
}
|