@delicious233/dida-cli 0.2.1 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +4 -3
  2. package/scripts/install.js +198 -56
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@delicious233/dida-cli",
3
- "version": "0.2.1",
3
+ "version": "0.2.4",
4
4
  "private": false,
5
- "description": "Agent-friendly CLI for Dida365 / TickTick task automation with stable JSON output.",
5
+ "description": "JSON-first CLI for Dida365 / TickTick task automation with stable JSON output.",
6
6
  "bin": {
7
7
  "dida": "bin/dida"
8
8
  },
9
9
  "scripts": {
10
- "postinstall": "node scripts/install.js"
10
+ "postinstall": "node scripts/install.js",
11
+ "test": "node --test scripts/install.test.js"
11
12
  },
12
13
  "files": [
13
14
  "bin/dida",
@@ -6,98 +6,224 @@ const path = require("path");
6
6
  const crypto = require("crypto");
7
7
  const https = require("https");
8
8
  const { execFileSync } = require("child_process");
9
+ const pkg = require("../package.json");
9
10
 
10
- const repo = process.env.DIDA_REPO || "DeliciousBuding/dida-cli";
11
- const version = process.env.DIDA_VERSION || "";
11
+ const defaultRepo = "DeliciousBuding/dida-cli";
12
+ const defaultMaxDownloadBytes = 200 * 1024 * 1024;
12
13
  const binDir = path.join(__dirname, "..", "bin");
13
14
 
14
- function platformName() {
15
- if (process.platform === "win32") return "windows";
16
- if (process.platform === "linux") return "linux";
17
- if (process.platform === "darwin") return "darwin";
18
- throw new Error(`unsupported platform: ${process.platform}`);
15
+ function platformName(platform = process.platform) {
16
+ if (platform === "win32") return "windows";
17
+ if (platform === "linux") return "linux";
18
+ if (platform === "darwin") return "darwin";
19
+ throw new Error(`unsupported platform: ${platform}`);
19
20
  }
20
21
 
21
- function archName() {
22
- if (process.arch === "x64") return "amd64";
23
- if (process.arch === "arm64") return "arm64";
24
- throw new Error(`unsupported architecture: ${process.arch}`);
22
+ function archName(arch = process.arch) {
23
+ if (arch === "x64") return "amd64";
24
+ if (arch === "arm64") return "arm64";
25
+ throw new Error(`unsupported architecture: ${arch}`);
25
26
  }
26
27
 
27
- function requestBuffer(url) {
28
- return new Promise((resolve, reject) => {
29
- const req = https.get(url, { headers: { "User-Agent": "dida-cli-npm-installer" }, timeout: 60000 }, (res) => {
30
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
31
- requestBuffer(res.headers.location).then(resolve, reject);
32
- return;
33
- }
34
- if (res.statusCode !== 200) {
35
- reject(new Error(`download failed ${res.statusCode}: ${url}`));
36
- return;
37
- }
38
- const chunks = [];
39
- res.on("data", (chunk) => chunks.push(chunk));
40
- res.on("end", () => resolve(Buffer.concat(chunks)));
41
- }).on("error", reject);
42
- req.on("timeout", () => { req.destroy(); reject(new Error(`request timed out: ${url}`)); });
43
- });
44
- }
45
-
46
- function sha256(buffer) {
47
- return crypto.createHash("sha256").update(buffer).digest("hex");
48
- }
49
-
50
- async function main() {
51
- const osName = platformName();
52
- const arch = archName();
28
+ function createInstallPlan(options = {}) {
29
+ const env = options.env || process.env;
30
+ const packageJson = options.packageJson || pkg;
31
+ const repo = options.repo || env.DIDA_REPO || defaultRepo;
32
+ const version = env.DIDA_VERSION || `v${packageJson.version}`;
33
+ const osName = platformName(options.platform);
34
+ const arch = archName(options.arch);
53
35
  const ext = osName === "windows" ? "zip" : "tar.gz";
54
36
  const exe = osName === "windows" ? "dida.exe" : "dida";
55
37
  const installedExe = osName === "windows" ? "dida.exe" : "dida-bin";
56
- let resolvedVersion = version;
57
- let base = version
38
+ const base = version
58
39
  ? `https://github.com/${repo}/releases/download/${version}`
59
40
  : `https://github.com/${repo}/releases/latest/download`;
60
- const checksums = await requestBuffer(`${base}/checksums.txt`);
41
+
42
+ return { repo, version, osName, arch, ext, exe, installedExe, base };
43
+ }
44
+
45
+ function escapeRegExp(value) {
46
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
47
+ }
48
+
49
+ function resolveAssetFromChecksums(checksumsText, plan) {
50
+ let resolvedVersion = plan.version;
61
51
  let asset = "";
62
52
 
63
53
  if (resolvedVersion) {
64
- asset = `dida_${resolvedVersion}_${osName}_${arch}.${ext}`;
54
+ asset = `dida_${resolvedVersion}_${plan.osName}_${plan.arch}.${plan.ext}`;
65
55
  } else {
66
- const suffix = `_${osName}_${arch}.${ext}`;
67
- asset = checksums.toString("utf8").split(/\r?\n/)
56
+ const suffix = `_${plan.osName}_${plan.arch}.${plan.ext}`;
57
+ asset = checksumsText.split(/\r?\n/)
68
58
  .map((line) => line.trim().split(/\s+/)[1])
69
- .find((name) => name && name.startsWith("dida_v") && name.endsWith(suffix));
59
+ .find((name) => name && name.startsWith("dida_v") && name.endsWith(suffix)) || "";
70
60
  if (asset) {
71
- const match = asset.match(new RegExp(`^dida_(v[^_]+)_${osName}_${arch}\\.${ext.replace(".", "\\.")}$`));
61
+ const pattern = `^dida_(v[^_]+)_${plan.osName}_${plan.arch}\\.${escapeRegExp(plan.ext)}$`;
62
+ const match = asset.match(new RegExp(pattern));
72
63
  if (match) resolvedVersion = match[1];
73
64
  }
74
65
  }
66
+
75
67
  if (!resolvedVersion || !asset) {
76
- throw new Error(`could not resolve latest release asset for ${osName}/${arch}`);
68
+ throw new Error(`could not resolve latest release asset for ${plan.osName}/${plan.arch}`);
77
69
  }
70
+ return { asset, resolvedVersion };
71
+ }
72
+
73
+ function sleep(ms) {
74
+ return new Promise((resolve) => setTimeout(resolve, ms));
75
+ }
78
76
 
79
- const archive = await requestBuffer(`${base}/${asset}`);
80
- const line = checksums.toString("utf8").split(/\r?\n/).find((item) => item.endsWith(` ${asset}`));
77
+ function responseTooLargeError(url, actualBytes, maxBytes) {
78
+ return new Error(`response too large for ${url}: ${actualBytes} bytes exceeds ${maxBytes} bytes`);
79
+ }
80
+
81
+ function retryableError(message) {
82
+ const error = new Error(message);
83
+ error.retryable = true;
84
+ return error;
85
+ }
86
+
87
+ function shouldRetryDownload(error) {
88
+ return error && error.retryable === true;
89
+ }
90
+
91
+ function requestBuffer(url, options = {}) {
92
+ const requestOptions = typeof options === "number" ? { redirects: options } : options;
93
+ const redirects = requestOptions.redirects || 0;
94
+ const maxRedirects = requestOptions.maxRedirects ?? 5;
95
+ const retries = requestOptions.retries ?? 2;
96
+ const retryDelayMs = requestOptions.retryDelayMs ?? 250;
97
+ const wait = requestOptions.wait || requestOptions.sleep || sleep;
98
+ const get = requestOptions.get || https.get;
99
+
100
+ return requestBufferWithRetries(url, { ...requestOptions, redirects, maxRedirects, retries, retryDelayMs, wait, get });
101
+ }
102
+
103
+ async function requestBufferWithRetries(url, requestOptions) {
104
+ let lastError = null;
105
+ for (let attempt = 0; attempt <= requestOptions.retries; attempt += 1) {
106
+ try {
107
+ return await requestBufferOnce(url, requestOptions);
108
+ } catch (error) {
109
+ lastError = error;
110
+ if (attempt >= requestOptions.retries || !shouldRetryDownload(error)) {
111
+ if (attempt > 0) {
112
+ throw new Error(`download failed after ${attempt + 1} attempts: ${error.message}`);
113
+ }
114
+ throw error;
115
+ }
116
+ const delay = requestOptions.retryDelayMs * (2 ** attempt);
117
+ if (delay > 0) await requestOptions.wait(delay);
118
+ else await requestOptions.wait(0);
119
+ }
120
+ }
121
+ throw lastError;
122
+ }
123
+
124
+ function requestBufferOnce(url, requestOptions) {
125
+ const maxBytes = requestOptions.maxBytes ?? defaultMaxDownloadBytes;
126
+
127
+ return new Promise((resolve, reject) => {
128
+ let settled = false;
129
+ const finish = (error, value) => {
130
+ if (settled) return;
131
+ settled = true;
132
+ if (error) reject(error);
133
+ else resolve(value);
134
+ };
135
+
136
+ const req = requestOptions.get(url, { headers: { "User-Agent": "dida-cli-npm-installer" }, timeout: 60000 }, (res) => {
137
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
138
+ res.resume();
139
+ if (requestOptions.redirects >= requestOptions.maxRedirects) {
140
+ finish(new Error(`too many redirects: ${url}`));
141
+ return;
142
+ }
143
+ const next = new URL(res.headers.location, url).toString();
144
+ requestBuffer(next, { ...requestOptions, redirects: requestOptions.redirects + 1 })
145
+ .then((value) => finish(null, value), (error) => finish(error));
146
+ return;
147
+ }
148
+ if (res.statusCode !== 200) {
149
+ res.resume();
150
+ const error = res.statusCode >= 500
151
+ ? retryableError(`download failed ${res.statusCode}: ${url}`)
152
+ : new Error(`download failed ${res.statusCode}: ${url}`);
153
+ finish(error);
154
+ return;
155
+ }
156
+ const contentLength = Number(res.headers["content-length"]);
157
+ if (Number.isFinite(contentLength) && contentLength > maxBytes) {
158
+ res.resume();
159
+ finish(responseTooLargeError(url, contentLength, maxBytes));
160
+ return;
161
+ }
162
+ const chunks = [];
163
+ let received = 0;
164
+ res.on("data", (chunk) => {
165
+ received += chunk.length;
166
+ if (received > maxBytes) {
167
+ if (typeof res.destroy === "function") res.destroy();
168
+ finish(responseTooLargeError(url, received, maxBytes));
169
+ return;
170
+ }
171
+ chunks.push(chunk);
172
+ });
173
+ res.on("end", () => finish(null, Buffer.concat(chunks)));
174
+ res.on("error", (error) => {
175
+ error.retryable = true;
176
+ finish(error);
177
+ });
178
+ }).on("error", (error) => {
179
+ error.retryable = true;
180
+ finish(error);
181
+ });
182
+ req.on("timeout", () => {
183
+ req.destroy();
184
+ finish(retryableError(`request timed out: ${url}`));
185
+ });
186
+ });
187
+ }
188
+
189
+ function sha256(buffer) {
190
+ return crypto.createHash("sha256").update(buffer).digest("hex");
191
+ }
192
+
193
+ function verifyArchiveChecksum(checksumsText, asset, archive) {
194
+ const line = checksumsText.split(/\r?\n/).find((item) => item.endsWith(` ${asset}`));
81
195
  if (!line) throw new Error(`checksum not found for ${asset}`);
82
196
  const expected = line.split(/\s+/)[0].toLowerCase();
83
197
  const actual = sha256(archive);
84
198
  if (actual !== expected) throw new Error(`checksum mismatch for ${asset}`);
199
+ return expected;
200
+ }
201
+
202
+ async function main(options = {}) {
203
+ const plan = createInstallPlan(options);
204
+ const download = options.requestBuffer || requestBuffer;
205
+ const checksums = await download(`${plan.base}/checksums.txt`);
206
+ const checksumsText = checksums.toString("utf8");
207
+ const { asset } = resolveAssetFromChecksums(checksumsText, plan);
208
+
209
+ const archive = await download(`${plan.base}/${asset}`);
210
+ verifyArchiveChecksum(checksumsText, asset, archive);
85
211
 
86
212
  const temp = fs.mkdtempSync(path.join(os.tmpdir(), "dida-npm-"));
87
213
  try {
88
214
  const archivePath = path.join(temp, asset);
89
215
  fs.writeFileSync(archivePath, archive);
90
- if (ext === "zip") {
216
+ if (plan.ext === "zip") {
91
217
  execFileSync("powershell", ["-NoProfile", "-Command", `Expand-Archive -LiteralPath '${archivePath.replace(/'/g, "''")}' -DestinationPath '${temp.replace(/'/g, "''")}' -Force`], { stdio: "inherit" });
92
218
  } else {
93
219
  execFileSync("tar", ["-xzf", archivePath, "-C", temp], { stdio: "inherit" });
94
220
  }
95
- const found = findFile(temp, exe);
221
+ const found = findFile(temp, plan.exe);
96
222
  if (!found) throw new Error("binary not found in archive");
97
223
  fs.mkdirSync(binDir, { recursive: true });
98
- const target = path.join(binDir, installedExe);
224
+ const target = path.join(binDir, plan.installedExe);
99
225
  fs.copyFileSync(found, target);
100
- if (osName !== "windows") fs.chmodSync(target, 0o755);
226
+ if (plan.osName !== "windows") fs.chmodSync(target, 0o755);
101
227
  } finally {
102
228
  fs.rmSync(temp, { recursive: true, force: true });
103
229
  }
@@ -116,7 +242,23 @@ function findFile(dir, fileName) {
116
242
  return null;
117
243
  }
118
244
 
119
- main().catch((error) => {
120
- console.error(`dida-cli install failed: ${error.message}`);
121
- process.exit(1);
122
- });
245
+ module.exports = {
246
+ archName,
247
+ createInstallPlan,
248
+ findFile,
249
+ main,
250
+ platformName,
251
+ requestBuffer,
252
+ responseTooLargeError,
253
+ resolveAssetFromChecksums,
254
+ sha256,
255
+ shouldRetryDownload,
256
+ verifyArchiveChecksum,
257
+ };
258
+
259
+ if (require.main === module) {
260
+ main().catch((error) => {
261
+ console.error(`dida-cli install failed: ${error.message}`);
262
+ process.exit(1);
263
+ });
264
+ }