@mison/wecom-cleaner 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/updater.js ADDED
@@ -0,0 +1,503 @@
1
+ import { spawnSync } from 'node:child_process';
2
+
3
+ const DEFAULT_TIMEOUT_MS = 2500;
4
+
5
+ function parseSemver(rawVersion) {
6
+ const text = String(rawVersion || '')
7
+ .trim()
8
+ .replace(/^v/i, '');
9
+ const matched = text.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
10
+ if (!matched) {
11
+ return null;
12
+ }
13
+ return {
14
+ raw: text,
15
+ major: Number.parseInt(matched[1], 10),
16
+ minor: Number.parseInt(matched[2], 10),
17
+ patch: Number.parseInt(matched[3], 10),
18
+ prerelease: matched[4] || '',
19
+ };
20
+ }
21
+
22
+ function comparePrerelease(a, b) {
23
+ const aText = String(a || '');
24
+ const bText = String(b || '');
25
+ if (!aText && !bText) {
26
+ return 0;
27
+ }
28
+ if (!aText) {
29
+ return 1;
30
+ }
31
+ if (!bText) {
32
+ return -1;
33
+ }
34
+ const aSegs = aText.split('.');
35
+ const bSegs = bText.split('.');
36
+ const max = Math.max(aSegs.length, bSegs.length);
37
+ for (let i = 0; i < max; i += 1) {
38
+ const aSeg = aSegs[i];
39
+ const bSeg = bSegs[i];
40
+ if (aSeg === undefined) {
41
+ return -1;
42
+ }
43
+ if (bSeg === undefined) {
44
+ return 1;
45
+ }
46
+ const aNum = /^\d+$/.test(aSeg) ? Number.parseInt(aSeg, 10) : null;
47
+ const bNum = /^\d+$/.test(bSeg) ? Number.parseInt(bSeg, 10) : null;
48
+ if (aNum !== null && bNum !== null) {
49
+ if (aNum !== bNum) {
50
+ return aNum > bNum ? 1 : -1;
51
+ }
52
+ continue;
53
+ }
54
+ if (aNum !== null) {
55
+ return -1;
56
+ }
57
+ if (bNum !== null) {
58
+ return 1;
59
+ }
60
+ if (aSeg !== bSeg) {
61
+ return aSeg > bSeg ? 1 : -1;
62
+ }
63
+ }
64
+ return 0;
65
+ }
66
+
67
+ export function compareVersion(a, b) {
68
+ const left = parseSemver(a);
69
+ const right = parseSemver(b);
70
+ if (!left || !right) {
71
+ return 0;
72
+ }
73
+ if (left.major !== right.major) {
74
+ return left.major > right.major ? 1 : -1;
75
+ }
76
+ if (left.minor !== right.minor) {
77
+ return left.minor > right.minor ? 1 : -1;
78
+ }
79
+ if (left.patch !== right.patch) {
80
+ return left.patch > right.patch ? 1 : -1;
81
+ }
82
+ return comparePrerelease(left.prerelease, right.prerelease);
83
+ }
84
+
85
+ export function isValidSemver(version) {
86
+ return Boolean(parseSemver(version));
87
+ }
88
+
89
+ export function normalizeVersion(version) {
90
+ const parsed = parseSemver(version);
91
+ return parsed ? parsed.raw : '';
92
+ }
93
+
94
+ function isPrerelease(version) {
95
+ const parsed = parseSemver(version);
96
+ return Boolean(parsed?.prerelease);
97
+ }
98
+
99
+ export function defaultSelfUpdateConfig() {
100
+ return {
101
+ enabled: true,
102
+ channel: 'stable',
103
+ checkSchedule: 'tri_daily',
104
+ autoCheckOnStartup: true,
105
+ lastCheckAt: 0,
106
+ lastCheckSlot: '',
107
+ lastKnownLatest: '',
108
+ lastKnownSource: '',
109
+ skipVersion: '',
110
+ };
111
+ }
112
+
113
+ export function normalizeSelfUpdateConfig(input, fallback = defaultSelfUpdateConfig()) {
114
+ const source = input && typeof input === 'object' ? input : {};
115
+ const fallbackConfig = fallback && typeof fallback === 'object' ? fallback : defaultSelfUpdateConfig();
116
+ const channel =
117
+ source.channel === 'pre' ? 'pre' : source.channel === 'stable' ? 'stable' : fallbackConfig.channel;
118
+ return {
119
+ enabled: typeof source.enabled === 'boolean' ? source.enabled : Boolean(fallbackConfig.enabled),
120
+ channel,
121
+ checkSchedule:
122
+ typeof source.checkSchedule === 'string' && source.checkSchedule.trim()
123
+ ? source.checkSchedule.trim()
124
+ : fallbackConfig.checkSchedule,
125
+ autoCheckOnStartup:
126
+ typeof source.autoCheckOnStartup === 'boolean'
127
+ ? source.autoCheckOnStartup
128
+ : Boolean(fallbackConfig.autoCheckOnStartup),
129
+ lastCheckAt: Number.isFinite(Number(source.lastCheckAt))
130
+ ? Number(source.lastCheckAt)
131
+ : Number(fallbackConfig.lastCheckAt || 0),
132
+ lastCheckSlot:
133
+ typeof source.lastCheckSlot === 'string' && source.lastCheckSlot.trim()
134
+ ? source.lastCheckSlot.trim()
135
+ : String(fallbackConfig.lastCheckSlot || ''),
136
+ lastKnownLatest:
137
+ typeof source.lastKnownLatest === 'string' && source.lastKnownLatest.trim()
138
+ ? normalizeVersion(source.lastKnownLatest)
139
+ : normalizeVersion(fallbackConfig.lastKnownLatest),
140
+ lastKnownSource:
141
+ typeof source.lastKnownSource === 'string' && source.lastKnownSource.trim()
142
+ ? source.lastKnownSource.trim()
143
+ : String(fallbackConfig.lastKnownSource || ''),
144
+ skipVersion:
145
+ typeof source.skipVersion === 'string' && source.skipVersion.trim()
146
+ ? normalizeVersion(source.skipVersion)
147
+ : normalizeVersion(fallbackConfig.skipVersion),
148
+ };
149
+ }
150
+
151
+ export function resolveCheckSlot(now = new Date()) {
152
+ const hour = Number(now.getHours());
153
+ if (!Number.isFinite(hour)) {
154
+ return '';
155
+ }
156
+ if (hour >= 5 && hour <= 10) {
157
+ return 'morning';
158
+ }
159
+ if (hour >= 11 && hour <= 15) {
160
+ return 'noon';
161
+ }
162
+ if (hour >= 16 && hour <= 23) {
163
+ return 'evening';
164
+ }
165
+ return '';
166
+ }
167
+
168
+ function isSameLocalDay(tsA, tsB) {
169
+ const dateA = new Date(Number(tsA || 0));
170
+ const dateB = new Date(Number(tsB || 0));
171
+ return (
172
+ dateA.getFullYear() === dateB.getFullYear() &&
173
+ dateA.getMonth() === dateB.getMonth() &&
174
+ dateA.getDate() === dateB.getDate()
175
+ );
176
+ }
177
+
178
+ export function shouldCheckForUpdate(config, now = Date.now()) {
179
+ const normalized = normalizeSelfUpdateConfig(config);
180
+ if (!normalized.enabled || !normalized.autoCheckOnStartup) {
181
+ return { shouldCheck: false, reason: 'disabled', slot: '' };
182
+ }
183
+ if (normalized.checkSchedule !== 'tri_daily') {
184
+ return { shouldCheck: true, reason: 'custom_schedule', slot: resolveCheckSlot(new Date(now)) };
185
+ }
186
+ const slot = resolveCheckSlot(new Date(now));
187
+ if (!slot) {
188
+ return { shouldCheck: false, reason: 'quiet_time', slot: '' };
189
+ }
190
+ if (
191
+ normalized.lastCheckAt > 0 &&
192
+ isSameLocalDay(normalized.lastCheckAt, now) &&
193
+ normalized.lastCheckSlot === slot
194
+ ) {
195
+ return { shouldCheck: false, reason: 'already_checked_in_slot', slot };
196
+ }
197
+ return { shouldCheck: true, reason: 'slot_due', slot };
198
+ }
199
+
200
+ async function fetchJson(url, fetchImpl, timeoutMs = DEFAULT_TIMEOUT_MS) {
201
+ const controller = new AbortController();
202
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
203
+ if (typeof timer.unref === 'function') {
204
+ timer.unref();
205
+ }
206
+ try {
207
+ const response = await fetchImpl(url, {
208
+ method: 'GET',
209
+ signal: controller.signal,
210
+ headers: {
211
+ 'user-agent': 'wecom-cleaner-updater',
212
+ accept: 'application/json',
213
+ },
214
+ });
215
+ if (!response.ok) {
216
+ throw new Error(`HTTP ${response.status}`);
217
+ }
218
+ return await response.json();
219
+ } finally {
220
+ clearTimeout(timer);
221
+ }
222
+ }
223
+
224
+ export async function fetchLatestFromNpm({
225
+ packageName,
226
+ channel = 'stable',
227
+ fetchImpl = fetch,
228
+ timeoutMs = DEFAULT_TIMEOUT_MS,
229
+ }) {
230
+ const encoded = encodeURIComponent(String(packageName || '').trim());
231
+ const url = `https://registry.npmjs.org/${encoded}`;
232
+ const payload = await fetchJson(url, fetchImpl, timeoutMs);
233
+ const distTags = payload?.['dist-tags'] || {};
234
+ const latest = channel === 'pre' ? distTags.next || distTags.latest : distTags.latest;
235
+ const version = normalizeVersion(latest);
236
+ if (!version) {
237
+ throw new Error('npm_dist_tag_missing');
238
+ }
239
+ return {
240
+ source: 'npm',
241
+ version,
242
+ raw: payload,
243
+ };
244
+ }
245
+
246
+ function normalizeGitTagVersion(tagName) {
247
+ const normalized = normalizeVersion(tagName);
248
+ return normalized || '';
249
+ }
250
+
251
+ function pickGithubRelease(releases, channel) {
252
+ const list = Array.isArray(releases) ? releases : [];
253
+ if (channel === 'pre') {
254
+ return list.find((item) => item && item.draft !== true) || null;
255
+ }
256
+ return list.find((item) => item && item.draft !== true && item.prerelease !== true) || null;
257
+ }
258
+
259
+ export async function fetchLatestFromGithub({
260
+ owner,
261
+ repo,
262
+ channel = 'stable',
263
+ fetchImpl = fetch,
264
+ timeoutMs = DEFAULT_TIMEOUT_MS,
265
+ }) {
266
+ const base = `https://api.github.com/repos/${owner}/${repo}`;
267
+ if (channel === 'stable') {
268
+ const payload = await fetchJson(`${base}/releases/latest`, fetchImpl, timeoutMs);
269
+ const version = normalizeGitTagVersion(payload?.tag_name);
270
+ if (!version) {
271
+ throw new Error('github_latest_tag_missing');
272
+ }
273
+ return {
274
+ source: 'github',
275
+ version,
276
+ raw: payload,
277
+ };
278
+ }
279
+
280
+ const releases = await fetchJson(`${base}/releases?per_page=20`, fetchImpl, timeoutMs);
281
+ const selected = pickGithubRelease(releases, channel);
282
+ const version = normalizeGitTagVersion(selected?.tag_name);
283
+ if (!version) {
284
+ throw new Error('github_release_not_found');
285
+ }
286
+ return {
287
+ source: 'github',
288
+ version,
289
+ raw: selected,
290
+ };
291
+ }
292
+
293
+ export async function checkLatestVersion({
294
+ currentVersion,
295
+ packageName,
296
+ githubOwner,
297
+ githubRepo,
298
+ channel = 'stable',
299
+ fetchImpl = fetch,
300
+ timeoutMs = DEFAULT_TIMEOUT_MS,
301
+ reason = 'manual',
302
+ }) {
303
+ const current = normalizeVersion(currentVersion);
304
+ const normalizedChannel = channel === 'pre' ? 'pre' : 'stable';
305
+ const errors = [];
306
+
307
+ try {
308
+ const npmResult = await fetchLatestFromNpm({
309
+ packageName,
310
+ channel: normalizedChannel,
311
+ fetchImpl,
312
+ timeoutMs,
313
+ });
314
+ const latest = npmResult.version;
315
+ return {
316
+ checked: true,
317
+ currentVersion: current,
318
+ latestVersion: latest,
319
+ hasUpdate: compareVersion(latest, current) > 0,
320
+ sourceUsed: 'npm',
321
+ channel: normalizedChannel,
322
+ checkReason: reason,
323
+ checkedAt: Date.now(),
324
+ errors,
325
+ upgradeMethods: ['npm', 'github-script'],
326
+ };
327
+ } catch (error) {
328
+ errors.push(`npm检查失败: ${error instanceof Error ? error.message : String(error)}`);
329
+ }
330
+
331
+ try {
332
+ const githubResult = await fetchLatestFromGithub({
333
+ owner: githubOwner,
334
+ repo: githubRepo,
335
+ channel: normalizedChannel,
336
+ fetchImpl,
337
+ timeoutMs,
338
+ });
339
+ const latest = githubResult.version;
340
+ return {
341
+ checked: true,
342
+ currentVersion: current,
343
+ latestVersion: latest,
344
+ hasUpdate: compareVersion(latest, current) > 0,
345
+ sourceUsed: 'github',
346
+ channel: normalizedChannel,
347
+ checkReason: reason,
348
+ checkedAt: Date.now(),
349
+ errors,
350
+ upgradeMethods: ['npm', 'github-script'],
351
+ };
352
+ } catch (error) {
353
+ errors.push(`github检查失败: ${error instanceof Error ? error.message : String(error)}`);
354
+ }
355
+
356
+ return {
357
+ checked: false,
358
+ currentVersion: current,
359
+ latestVersion: null,
360
+ hasUpdate: false,
361
+ sourceUsed: 'none',
362
+ channel: normalizedChannel,
363
+ checkReason: reason,
364
+ checkedAt: Date.now(),
365
+ errors,
366
+ upgradeMethods: ['npm', 'github-script'],
367
+ };
368
+ }
369
+
370
+ export function applyUpdateCheckResult(config, checkResult, slot = '') {
371
+ const normalized = normalizeSelfUpdateConfig(config);
372
+ const result = checkResult && typeof checkResult === 'object' ? checkResult : {};
373
+ normalized.lastCheckAt = Number(result.checkedAt || Date.now());
374
+ normalized.lastCheckSlot = slot || '';
375
+ normalized.lastKnownLatest = normalizeVersion(result.latestVersion || '');
376
+ normalized.lastKnownSource = String(result.sourceUsed || '');
377
+ return normalized;
378
+ }
379
+
380
+ function buildVersionRef(version) {
381
+ const normalized = normalizeVersion(version);
382
+ if (!normalized) {
383
+ return '';
384
+ }
385
+ return normalized.startsWith('v') ? normalized : `v${normalized}`;
386
+ }
387
+
388
+ export function githubUpgradeScriptUrl({ owner, repo, version }) {
389
+ const ref = buildVersionRef(version) || 'main';
390
+ return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/scripts/upgrade.sh`;
391
+ }
392
+
393
+ function defaultRunCommand(command, args) {
394
+ const result = spawnSync(command, args, {
395
+ encoding: 'utf-8',
396
+ stdio: 'pipe',
397
+ });
398
+ return {
399
+ status: Number(result.status || 0),
400
+ stdout: String(result.stdout || ''),
401
+ stderr: String(result.stderr || ''),
402
+ error: result.error || null,
403
+ };
404
+ }
405
+
406
+ export function runUpgrade({
407
+ method,
408
+ packageName,
409
+ targetVersion,
410
+ githubOwner,
411
+ githubRepo,
412
+ runCommand = defaultRunCommand,
413
+ }) {
414
+ const chosen = method === 'github-script' ? 'github-script' : 'npm';
415
+ const normalizedVersion = normalizeVersion(targetVersion);
416
+ const npmSpec = normalizedVersion ? `${packageName}@${normalizedVersion}` : `${packageName}@latest`;
417
+
418
+ if (chosen === 'npm') {
419
+ const result = runCommand('npm', ['i', '-g', npmSpec]);
420
+ return {
421
+ method: chosen,
422
+ targetVersion: normalizedVersion || 'latest',
423
+ command: `npm i -g ${npmSpec}`,
424
+ ok: result.status === 0 && !result.error,
425
+ status: result.status,
426
+ stdout: result.stdout,
427
+ stderr: result.stderr,
428
+ error: result.error ? String(result.error.message || result.error) : '',
429
+ };
430
+ }
431
+
432
+ const scriptUrl = githubUpgradeScriptUrl({
433
+ owner: githubOwner,
434
+ repo: githubRepo,
435
+ version: normalizedVersion || 'main',
436
+ });
437
+ const versionArg = normalizedVersion ? ` --version ${normalizedVersion}` : '';
438
+ const commandText = `curl -fsSL ${scriptUrl} | bash -s --${versionArg}`;
439
+ const result = runCommand('bash', ['-lc', commandText]);
440
+ return {
441
+ method: chosen,
442
+ targetVersion: normalizedVersion || 'latest',
443
+ command: commandText,
444
+ ok: result.status === 0 && !result.error,
445
+ status: result.status,
446
+ stdout: result.stdout,
447
+ stderr: result.stderr,
448
+ error: result.error ? String(result.error.message || result.error) : '',
449
+ };
450
+ }
451
+
452
+ export function updateWarningMessage(checkResult, skipVersion = '') {
453
+ const result = checkResult && typeof checkResult === 'object' ? checkResult : {};
454
+ if (!result.hasUpdate) {
455
+ return '';
456
+ }
457
+ const latest = normalizeVersion(result.latestVersion);
458
+ const current = normalizeVersion(result.currentVersion);
459
+ if (!latest || latest === normalizeVersion(skipVersion)) {
460
+ return '';
461
+ }
462
+ return `检测到新版本 v${latest}(当前 v${current || 'unknown'},来源 ${result.sourceUsed || 'unknown'})。可使用 --upgrade npm --upgrade-yes 升级。`;
463
+ }
464
+
465
+ export function shouldSkipVersion(checkResult, skipVersion = '') {
466
+ const latest = normalizeVersion(checkResult?.latestVersion || '');
467
+ const skip = normalizeVersion(skipVersion || '');
468
+ return Boolean(latest && skip && latest === skip);
469
+ }
470
+
471
+ export function channelLabel(channel) {
472
+ return channel === 'pre' ? '预发布' : '稳定版';
473
+ }
474
+
475
+ export function normalizeUpgradeMethod(raw) {
476
+ const value = String(raw || '')
477
+ .trim()
478
+ .toLowerCase();
479
+ if (value === 'npm' || value === 'github-script') {
480
+ return value;
481
+ }
482
+ return '';
483
+ }
484
+
485
+ export function normalizeUpgradeChannel(raw, fallback = 'stable') {
486
+ const value = String(raw || '')
487
+ .trim()
488
+ .toLowerCase();
489
+ if (value === 'stable' || value === 'pre') {
490
+ return value;
491
+ }
492
+ return fallback === 'pre' ? 'pre' : 'stable';
493
+ }
494
+
495
+ export function filterByChannel(version, channel = 'stable') {
496
+ if (!isValidSemver(version)) {
497
+ return false;
498
+ }
499
+ if (channel === 'pre') {
500
+ return true;
501
+ }
502
+ return !isPrerelease(version);
503
+ }