dsh-niuma 0.2.2 → 0.2.3
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/CHANGELOG.md +18 -0
- package/README.md +2 -1
- package/docs/preview-light-dark.png +0 -0
- package/lib/client.js +193 -161
- package/lib/client.js.map +2 -2
- package/lib/index.js +93 -3
- package/lib/update.js +247 -0
- package/package.json +4 -3
- package/src/client/index.js +154 -122
package/lib/index.js
CHANGED
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
* 职责(详见 SPEC.md §5):
|
|
5
5
|
* 1. 设置:JSON 文件持久化($DSH_HOME/niuma/config.json,可用 NIUMA_CONFIG_PATH 覆盖)。
|
|
6
6
|
* —— 不依赖 DSH 的 settings 服务:desktop 0.2.1-rc.2 里该服务缺席时写接口会永远 400。
|
|
7
|
-
* 2. 路由:通过 ctx.webServer
|
|
8
|
-
* GET /niuma/config -> { ok, data: { config, defaults, holidaySource } }
|
|
7
|
+
* 2. 路由:通过 ctx.webServer 暴露四条路由,供浏览器半侧 fetch:
|
|
8
|
+
* GET /niuma/config -> { ok, data: { config, defaults, holidaySource, version } }
|
|
9
9
|
* POST /niuma/settings -> 校验并持久化配置(body 上限 64KB)
|
|
10
10
|
* GET /niuma/health -> { ok, data: { name, version, aliveAt } }
|
|
11
|
+
* GET/POST /niuma/update -> 版本检查 / 下载新版本到暂存区(下次启动生效)
|
|
11
12
|
* 3. 鉴权:全部走 connection.requestRejection 守卫(与 DSH 内建路由同一套 Host/Origin 围栏)。
|
|
12
13
|
*
|
|
13
14
|
* 可靠性约束(0.x 插件血泪教训):
|
|
@@ -23,6 +24,10 @@ import { homedir } from 'node:os';
|
|
|
23
24
|
import { dirname, join } from 'node:path';
|
|
24
25
|
import { DEFAULTS, normalizeConfig, validateConfig } from '../src/core.mjs';
|
|
25
26
|
import { HOLIDAY_SOURCE } from '../src/holidays.mjs';
|
|
27
|
+
import {
|
|
28
|
+
applyStagedUpdate, checkAndStageUpdate, compareVersions, isDevCheckout,
|
|
29
|
+
pluginDirFromModule, readPluginMeta, readUpdateState, resolveUpdatePaths,
|
|
30
|
+
} from './update.js';
|
|
26
31
|
|
|
27
32
|
/** 包元数据:health 路由要报真实版本号;读不到也不能阻碍插件加载(用动态/容错读法) */
|
|
28
33
|
const PKG = (() => {
|
|
@@ -160,6 +165,38 @@ export async function apply(ctx, config = {}) {
|
|
|
160
165
|
const baseConfig = config && typeof config === 'object' && !Array.isArray(config) ? config : {};
|
|
161
166
|
const mountedAt = new Date().toISOString();
|
|
162
167
|
|
|
168
|
+
// ---- 自动更新:启动时先把上次暂存的新版本覆盖到插件目录(下次启动加载新代码) ----
|
|
169
|
+
const pluginDir = pluginDirFromModule(import.meta.url);
|
|
170
|
+
try {
|
|
171
|
+
const applied = applyStagedUpdate(pluginDir);
|
|
172
|
+
if (applied) logger?.info?.(`niuma: staged update applied -> v${applied.appliedVersion}(本次进程仍跑旧代码,重启后完全生效)`);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
logger?.warn?.(`niuma: staged update apply failed: ${error?.message ?? error}`);
|
|
175
|
+
}
|
|
176
|
+
const runningVersion = readPluginMeta(pluginDir).version || PKG.version || '0.0.0';
|
|
177
|
+
const updatePaths = resolveUpdatePaths(process.env);
|
|
178
|
+
const updateDisabled = process.env.NIUMA_UPDATE_DISABLED === '1';
|
|
179
|
+
const devCheckout = isDevCheckout(pluginDir);
|
|
180
|
+
|
|
181
|
+
/** 版本/更新状态快照(面板与 /niuma/update 共用)。 */
|
|
182
|
+
const updateInfo = () => {
|
|
183
|
+
const state = readUpdateState(updatePaths) || {};
|
|
184
|
+
const latest = state.latestVersion || runningVersion;
|
|
185
|
+
return {
|
|
186
|
+
currentVersion: runningVersion,
|
|
187
|
+
latestVersion: latest,
|
|
188
|
+
hasUpdate: compareVersions(latest, runningVersion) > 0,
|
|
189
|
+
staged: state.staged === true,
|
|
190
|
+
stagedVersion: state.staged === true ? latest : null,
|
|
191
|
+
appliedVersion: state.appliedVersion || null,
|
|
192
|
+
checkedAt: state.checkedAt || null,
|
|
193
|
+
error: state.error || null,
|
|
194
|
+
dev: devCheckout,
|
|
195
|
+
disabled: updateDisabled,
|
|
196
|
+
autoUpdate: baseConfig.autoUpdate !== false,
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
|
|
163
200
|
/** 配置文件路径:NIUMA_CONFIG_PATH(测试/高级用户)→ $DSH_HOME/niuma/config.json → ~/.dsh/niuma/config.json。 */
|
|
164
201
|
const configPath = resolveConfigPath(process.env);
|
|
165
202
|
|
|
@@ -236,6 +273,7 @@ export async function apply(ctx, config = {}) {
|
|
|
236
273
|
config: currentConfig(),
|
|
237
274
|
defaults: DEFAULTS,
|
|
238
275
|
holidaySource: HOLIDAY_SOURCE,
|
|
276
|
+
version: runningVersion,
|
|
239
277
|
},
|
|
240
278
|
});
|
|
241
279
|
};
|
|
@@ -300,12 +338,36 @@ export async function apply(ctx, config = {}) {
|
|
|
300
338
|
ok: true,
|
|
301
339
|
data: {
|
|
302
340
|
name,
|
|
303
|
-
version:
|
|
341
|
+
version: runningVersion,
|
|
304
342
|
aliveAt: mountedAt,
|
|
305
343
|
},
|
|
306
344
|
});
|
|
307
345
|
};
|
|
308
346
|
|
|
347
|
+
/** GET/POST /niuma/update:查看版本状态;POST 主动检查并下载新版到暂存区。 */
|
|
348
|
+
const handleUpdate = async (request, response) => {
|
|
349
|
+
const method = request?.method;
|
|
350
|
+
if (method !== 'GET' && method !== 'POST') {
|
|
351
|
+
methodNotAllowed(response, 'GET, POST');
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (method === 'POST') {
|
|
355
|
+
if (updateDisabled || devCheckout) {
|
|
356
|
+
sendJson(response, 200, { ok: true, data: updateInfo() });
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const result = await checkAndStageUpdate({
|
|
360
|
+
pluginDir,
|
|
361
|
+
currentVersion: runningVersion,
|
|
362
|
+
env: process.env,
|
|
363
|
+
paths: updatePaths,
|
|
364
|
+
});
|
|
365
|
+
if (result?.error) logger?.warn?.(`niuma: update check failed: ${result.error}`);
|
|
366
|
+
else if (result?.staged) logger?.info?.(`niuma: v${result.latestVersion} downloaded (applies on next start)`);
|
|
367
|
+
}
|
|
368
|
+
sendJson(response, 200, { ok: true, data: updateInfo() });
|
|
369
|
+
};
|
|
370
|
+
|
|
309
371
|
// ---- 「先做后清」:注册与清理都在同一个 effect 上,注册中途失败也不会泄漏 ----
|
|
310
372
|
const mountRoutes = () => {
|
|
311
373
|
const disposers = [];
|
|
@@ -317,6 +379,7 @@ export async function apply(ctx, config = {}) {
|
|
|
317
379
|
register(`${ROUTE_BASE}/config`, handleConfig);
|
|
318
380
|
register(`${ROUTE_BASE}/settings`, handleSettings);
|
|
319
381
|
register(`${ROUTE_BASE}/health`, handleHealth);
|
|
382
|
+
register(`${ROUTE_BASE}/update`, handleUpdate);
|
|
320
383
|
logger?.info?.(`niuma: routes mounted at ${ROUTE_BASE} (v${PKG.version ?? '?'})`);
|
|
321
384
|
} catch (error) {
|
|
322
385
|
logger?.warn?.(`niuma: route registration failed: ${error?.stack ?? error}`);
|
|
@@ -332,6 +395,33 @@ export async function apply(ctx, config = {}) {
|
|
|
332
395
|
};
|
|
333
396
|
};
|
|
334
397
|
|
|
398
|
+
// ---- 自动更新检查:启动后台查一次(git 工作区/显式关闭时跳过),只下载暂存、不覆盖运行中代码 ----
|
|
399
|
+
if (!updateDisabled && !devCheckout && baseConfig.autoUpdate !== false) {
|
|
400
|
+
const scheduleCheck = () => {
|
|
401
|
+
const timer = setTimeout(() => {
|
|
402
|
+
checkAndStageUpdate({
|
|
403
|
+
pluginDir,
|
|
404
|
+
currentVersion: runningVersion,
|
|
405
|
+
env: process.env,
|
|
406
|
+
paths: updatePaths,
|
|
407
|
+
})
|
|
408
|
+
.then((result) => {
|
|
409
|
+
if (result?.error) logger?.warn?.(`niuma: auto update check failed: ${result.error}`);
|
|
410
|
+
else if (result?.staged) logger?.info?.(`niuma: auto update v${result.latestVersion} staged (restart to apply)`);
|
|
411
|
+
})
|
|
412
|
+
.catch((error) => logger?.warn?.(`niuma: auto update crashed: ${error?.message ?? error}`));
|
|
413
|
+
}, 8000);
|
|
414
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
415
|
+
return () => clearTimeout(timer);
|
|
416
|
+
};
|
|
417
|
+
try {
|
|
418
|
+
if (typeof ctx.effect === 'function') ctx.effect(scheduleCheck, 'niuma: auto update check');
|
|
419
|
+
else scheduleCheck();
|
|
420
|
+
} catch (error) {
|
|
421
|
+
logger?.warn?.(`niuma: auto update schedule failed: ${error?.message ?? error}`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
335
425
|
try {
|
|
336
426
|
if (typeof ctx.effect === 'function') {
|
|
337
427
|
ctx.effect(mountRoutes, 'niuma: http routes');
|
package/lib/update.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 版本检查 / 自动更新(宿主半侧,纯 Node,无第三方依赖)。
|
|
3
|
+
*
|
|
4
|
+
* 机制:
|
|
5
|
+
* 1. `GET /niuma/update`:查 npm registry 上 `dsh-niuma` 的 latest,和本地版本比较(带缓存)。
|
|
6
|
+
* 2. `POST /niuma/update`:发现新版本时下载 tarball 解包到 `$DSH_HOME/niuma/updates/<version>/`。
|
|
7
|
+
* 3. **下次启动** apply() 时把暂存目录覆盖到插件目录(原子到文件级),新版本生效。
|
|
8
|
+
* 4. 开发用 git checkout(存在 .git)不自动更新,避免覆盖工作区;`NIUMA_UPDATE_DISABLED=1` 可整体关闭。
|
|
9
|
+
*
|
|
10
|
+
* 为什么用「下载暂存 + 重启生效」而不是在线替换:宿主 lib 已被 Node 加载、文件被占用;
|
|
11
|
+
* 下次启动前替换最安全,也避免升级到一半导致插件加载失败。
|
|
12
|
+
*/
|
|
13
|
+
import { gunzipSync } from 'node:zlib';
|
|
14
|
+
import {
|
|
15
|
+
cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync,
|
|
16
|
+
} from 'node:fs';
|
|
17
|
+
import { dirname, isAbsolute, join, normalize } from 'node:path';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
|
|
22
|
+
export const PACKAGE_NAME = 'dsh-niuma';
|
|
23
|
+
|
|
24
|
+
/** 解析版本号;返回 [major, minor, patch, prerelease] 或 null。 */
|
|
25
|
+
export function parseVersion(version) {
|
|
26
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(version || '').trim());
|
|
27
|
+
if (!match) return null;
|
|
28
|
+
return [Number(match[1]), Number(match[2]), Number(match[3]), match[4] || ''];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** semver 比较:a>b → 1,a<b → -1,相等 → 0。 */
|
|
32
|
+
export function compareVersions(a, b) {
|
|
33
|
+
const left = parseVersion(a);
|
|
34
|
+
const right = parseVersion(b);
|
|
35
|
+
if (!left || !right) return 0;
|
|
36
|
+
for (let i = 0; i < 3; i += 1) {
|
|
37
|
+
if (left[i] !== right[i]) return left[i] > right[i] ? 1 : -1;
|
|
38
|
+
}
|
|
39
|
+
if (left[3] === right[3]) return 0;
|
|
40
|
+
if (left[3] === '') return 1;
|
|
41
|
+
if (right[3] === '') return -1;
|
|
42
|
+
return left[3] > right[3] ? 1 : -1;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 更新相关路径(测试可用 DSH_HOME 指到临时目录)。 */
|
|
46
|
+
export function resolveUpdatePaths(env = process.env) {
|
|
47
|
+
const home = typeof env?.DSH_HOME === 'string' && env.DSH_HOME.trim()
|
|
48
|
+
? env.DSH_HOME.trim()
|
|
49
|
+
: join(homedir(), '.dsh');
|
|
50
|
+
const root = join(home, 'niuma');
|
|
51
|
+
return {
|
|
52
|
+
root,
|
|
53
|
+
staging: join(root, 'updates'),
|
|
54
|
+
stateFile: join(root, 'update-state.json'),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 读插件自身的 package.json(插件目录 = 本文件所在目录的上一级)。 */
|
|
59
|
+
export function readPluginMeta(pluginDir) {
|
|
60
|
+
try {
|
|
61
|
+
const pkg = JSON.parse(readFileSync(join(pluginDir, 'package.json'), 'utf8'));
|
|
62
|
+
return { name: String(pkg.name || ''), version: String(pkg.version || '0.0.0') };
|
|
63
|
+
} catch {
|
|
64
|
+
return { name: PACKAGE_NAME, version: '0.0.0' };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 开发 git checkout(有 .git)不自动更新,避免覆盖未提交改动。 */
|
|
69
|
+
export function isDevCheckout(pluginDir) {
|
|
70
|
+
try {
|
|
71
|
+
return existsSync(join(pluginDir, '.git'));
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readJsonSafe(file) {
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function writeJsonSafe(file, value) {
|
|
86
|
+
try {
|
|
87
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
88
|
+
writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
89
|
+
} catch {
|
|
90
|
+
/* 状态写失败不影响功能 */
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function readUpdateState(paths) {
|
|
95
|
+
return readJsonSafe(paths.stateFile) || null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function writeUpdateState(paths, state) {
|
|
99
|
+
writeJsonSafe(paths.stateFile, state);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 安全的 tar 解包(npm tarball = gzip + ustar,条目都在 package/ 下)。 */
|
|
103
|
+
export function extractTarballToDir(tarball, destDir) {
|
|
104
|
+
const tar = gunzipSync(tarball);
|
|
105
|
+
let offset = 0;
|
|
106
|
+
let pendingPath = null;
|
|
107
|
+
let fileCount = 0;
|
|
108
|
+
while (offset + 512 <= tar.length) {
|
|
109
|
+
const header = tar.subarray(offset, offset + 512);
|
|
110
|
+
const nameField = header.subarray(0, 100).toString('utf8').replace(/\0.*$/, '');
|
|
111
|
+
const sizeField = header.subarray(124, 136).toString('utf8').replace(/\0.*$/, '').trim();
|
|
112
|
+
const type = header.subarray(156, 157).toString('utf8') || '0';
|
|
113
|
+
const prefix = header.subarray(345, 500).toString('utf8').replace(/\0.*$/, '');
|
|
114
|
+
if (!nameField) break;
|
|
115
|
+
const size = parseInt(sizeField, 8) || 0;
|
|
116
|
+
const dataStart = offset + 512;
|
|
117
|
+
const dataEnd = dataStart + size;
|
|
118
|
+
if (dataEnd > tar.length) throw new Error(`tarball 截断:${nameField}`);
|
|
119
|
+
let name = nameField;
|
|
120
|
+
if (pendingPath) {
|
|
121
|
+
name = pendingPath;
|
|
122
|
+
pendingPath = null;
|
|
123
|
+
} else if (prefix) {
|
|
124
|
+
name = `${prefix}/${nameField}`;
|
|
125
|
+
}
|
|
126
|
+
if (type === 'L' || type === 'x' || type === 'g') {
|
|
127
|
+
// pax / long name:只处理长文件名,其余跳过
|
|
128
|
+
if (type === 'L') pendingPath = tar.subarray(dataStart, dataEnd).toString('utf8').replace(/\0.*$/, '');
|
|
129
|
+
offset = dataEnd + ((512 - (size % 512)) % 512);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (type === '0' || type === '\0' || type === '') {
|
|
133
|
+
const rel = normalize(name).replace(/^package\//, '').replace(/^package$/, '');
|
|
134
|
+
if (rel && !isAbsolute(rel) && !rel.startsWith('..')) {
|
|
135
|
+
const target = join(destDir, rel);
|
|
136
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
137
|
+
writeFileSync(target, tar.subarray(dataStart, dataEnd));
|
|
138
|
+
fileCount += 1;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
offset = dataEnd + ((512 - (size % 512)) % 512);
|
|
142
|
+
}
|
|
143
|
+
return fileCount;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function fetchWithTimeout(fetchImpl, url, timeoutMs) {
|
|
147
|
+
const controller = new AbortController();
|
|
148
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
149
|
+
try {
|
|
150
|
+
return await fetchImpl(url, {
|
|
151
|
+
signal: controller.signal,
|
|
152
|
+
headers: { accept: 'application/json, application/octet-stream', 'user-agent': `${PACKAGE_NAME}-updater` },
|
|
153
|
+
});
|
|
154
|
+
} finally {
|
|
155
|
+
clearTimeout(timer);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 检查 npm 上的最新版本;有新版则下载并解包到暂存目录。
|
|
161
|
+
* @returns {Promise<{currentVersion:string, latestVersion:string, hasUpdate:boolean, staged:boolean, stagedDir?:string, reason?:string, error?:string}>}
|
|
162
|
+
*/
|
|
163
|
+
export async function checkAndStageUpdate(options) {
|
|
164
|
+
const {
|
|
165
|
+
pluginDir,
|
|
166
|
+
currentVersion = readPluginMeta(pluginDir).version,
|
|
167
|
+
registry = DEFAULT_REGISTRY,
|
|
168
|
+
env = process.env,
|
|
169
|
+
fetchImpl = globalThis.fetch,
|
|
170
|
+
timeoutMs = 8000,
|
|
171
|
+
paths = resolveUpdatePaths(env),
|
|
172
|
+
} = options;
|
|
173
|
+
const base = { currentVersion, latestVersion: currentVersion, hasUpdate: false, staged: false };
|
|
174
|
+
if (isDevCheckout(pluginDir)) return { ...base, reason: 'dev-checkout' };
|
|
175
|
+
if (env?.NIUMA_UPDATE_DISABLED === '1') return { ...base, reason: 'disabled' };
|
|
176
|
+
if (typeof fetchImpl !== 'function') return { ...base, reason: 'fetch-unavailable', error: 'fetch unavailable' };
|
|
177
|
+
try {
|
|
178
|
+
const latestRes = await fetchWithTimeout(fetchImpl, `${registry.replace(/\/$/, '')}/${PACKAGE_NAME}/latest`, timeoutMs);
|
|
179
|
+
if (!latestRes || !latestRes.ok) throw new Error(`registry HTTP ${latestRes ? latestRes.status : 'no-response'}`);
|
|
180
|
+
const latest = await latestRes.json();
|
|
181
|
+
const latestVersion = String(latest?.version || '');
|
|
182
|
+
if (!parseVersion(latestVersion)) throw new Error(`非法版本号:${latestVersion}`);
|
|
183
|
+
if (compareVersions(latestVersion, currentVersion) <= 0) {
|
|
184
|
+
writeUpdateState(paths, { latestVersion, currentVersion, checkedAt: new Date().toISOString(), hasUpdate: false });
|
|
185
|
+
return { ...base, latestVersion, reason: 'up-to-date' };
|
|
186
|
+
}
|
|
187
|
+
const tarballUrl = String(latest?.dist?.tarball || `${registry.replace(/\/$/, '')}/${PACKAGE_NAME}/-/${PACKAGE_NAME}-${latestVersion}.tgz`);
|
|
188
|
+
const tarballRes = await fetchWithTimeout(fetchImpl, tarballUrl, timeoutMs);
|
|
189
|
+
if (!tarballRes || !tarballRes.ok) throw new Error(`tarball HTTP ${tarballRes ? tarballRes.status : 'no-response'}`);
|
|
190
|
+
const buffer = Buffer.from(await tarballRes.arrayBuffer());
|
|
191
|
+
const stagedDir = join(paths.staging, latestVersion);
|
|
192
|
+
rmSync(stagedDir, { recursive: true, force: true });
|
|
193
|
+
mkdirSync(stagedDir, { recursive: true });
|
|
194
|
+
const fileCount = extractTarballToDir(buffer, stagedDir);
|
|
195
|
+
const stagedMeta = readPluginMeta(stagedDir);
|
|
196
|
+
if (stagedMeta.version !== latestVersion) throw new Error(`暂存版本不符:${stagedMeta.version} != ${latestVersion}`);
|
|
197
|
+
const state = {
|
|
198
|
+
currentVersion, latestVersion, hasUpdate: true, staged: true,
|
|
199
|
+
stagedDir, stagedAt: new Date().toISOString(), checkedAt: new Date().toISOString(), fileCount,
|
|
200
|
+
};
|
|
201
|
+
writeUpdateState(paths, state);
|
|
202
|
+
return state;
|
|
203
|
+
} catch (error) {
|
|
204
|
+
const message = String(error?.message || error);
|
|
205
|
+
writeUpdateState(paths, { currentVersion, latestVersion: currentVersion, checkedAt: new Date().toISOString(), hasUpdate: false, error: message });
|
|
206
|
+
return { ...base, error: message };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 启动时应用已暂存的更新(把 updates/<version>/ 覆盖到插件目录)。
|
|
212
|
+
* @returns {{appliedVersion:string}|null}
|
|
213
|
+
*/
|
|
214
|
+
export function applyStagedUpdate(pluginDir, paths = resolveUpdatePaths(process.env)) {
|
|
215
|
+
const state = readUpdateState(paths);
|
|
216
|
+
if (!state || !state.latestVersion || !state.staged) return null;
|
|
217
|
+
const stagedDir = join(paths.staging, state.latestVersion);
|
|
218
|
+
if (!existsSync(stagedDir)) return null;
|
|
219
|
+
const current = readPluginMeta(pluginDir).version;
|
|
220
|
+
if (compareVersions(state.latestVersion, current) <= 0) {
|
|
221
|
+
rmSync(join(paths.staging, state.latestVersion), { recursive: true, force: true });
|
|
222
|
+
writeUpdateState(paths, { ...state, staged: false, appliedVersion: current, appliedAt: new Date().toISOString() });
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
cpSync(stagedDir, pluginDir, {
|
|
227
|
+
recursive: true,
|
|
228
|
+
force: true,
|
|
229
|
+
filter: (source) => {
|
|
230
|
+
const name = source.split(/[\\/]/).pop();
|
|
231
|
+
return name !== '.git' && name !== 'node_modules';
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
rmSync(join(paths.staging, state.latestVersion), { recursive: true, force: true });
|
|
235
|
+
writeUpdateState(paths, {
|
|
236
|
+
...state, staged: false, appliedVersion: state.latestVersion, appliedAt: new Date().toISOString(),
|
|
237
|
+
});
|
|
238
|
+
return { appliedVersion: state.latestVersion };
|
|
239
|
+
} catch {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** 插件的真实目录(lib/ 的上一级)。 */
|
|
245
|
+
export function pluginDirFromModule(moduleUrl) {
|
|
246
|
+
return dirname(dirname(fileURLToPath(moduleUrl)));
|
|
247
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-niuma",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "牛马加速器 — DSH 工资换算实时计数器:按实收月薪、上下班时间、午休(可设是否算工时)、法定节假日自动折算秒/分/时/日/周/月工资,工资每秒滚动增长。Community salary ticker plugin for DeepSeek Harness.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -87,9 +87,10 @@
|
|
|
87
87
|
"smoke:host": "node tools/smoke-host.mjs",
|
|
88
88
|
"smoke:client": "node tools/smoke-client.mjs",
|
|
89
89
|
"smoke:render": "node tools/smoke-render.mjs",
|
|
90
|
-
"test": "node tools/verify-core.mjs && node tools/verify-holidays.mjs && node tools/smoke-host.mjs && node tools/smoke-client.mjs && node tools/smoke-render.mjs",
|
|
90
|
+
"test": "node tools/verify-core.mjs && node tools/verify-holidays.mjs && node tools/verify-update.mjs && node tools/smoke-host.mjs && node tools/smoke-client.mjs && node tools/smoke-render.mjs",
|
|
91
91
|
"preview": "node tools/preview.mjs --open",
|
|
92
|
-
"prepublishOnly": "npm run build && npm test"
|
|
92
|
+
"prepublishOnly": "npm run build && npm test",
|
|
93
|
+
"verify:update": "node tools/verify-update.mjs"
|
|
93
94
|
},
|
|
94
95
|
"publishConfig": {
|
|
95
96
|
"registry": "https://registry.npmjs.org/"
|