dsh-niuma 0.1.0 → 0.2.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/lib/index.js CHANGED
@@ -2,7 +2,8 @@
2
2
  * 牛马加速器(dsh-niuma)—— 宿主半侧。
3
3
  *
4
4
  * 职责(详见 SPEC.md §5):
5
- * 1. 设置:可选注册 settings namespace(schemastery schema),把面板里的修改持久化到 DSH 设置。
5
+ * 1. 设置:JSON 文件持久化($DSH_HOME/niuma/config.json,可用 NIUMA_CONFIG_PATH 覆盖)。
6
+ * —— 不依赖 DSH 的 settings 服务:desktop 0.2.1-rc.2 里该服务缺席时写接口会永远 400。
6
7
  * 2. 路由:通过 ctx.webServer 暴露三条只读/写配置路由,供浏览器半侧 fetch:
7
8
  * GET /niuma/config -> { ok, data: { config, defaults, holidaySource } }
8
9
  * POST /niuma/settings -> 校验并持久化配置(body 上限 64KB)
@@ -10,14 +11,16 @@
10
11
  * 3. 鉴权:全部走 connection.requestRejection 守卫(与 DSH 内建路由同一套 Host/Origin 围栏)。
11
12
  *
12
13
  * 可靠性约束(0.x 插件血泪教训):
13
- * - settings / schemastery 都是**可选依赖**:动态 import 失败时静默降级,
14
- * 插件仍以 cordis.patch.yml 的 config + core.DEFAULTS 工作,绝不拖垮 DSH 启动。
14
+ * - 配置读写只用 node:fs;目录/文件权限 0700/0600,原子写(tmp + rename)。
15
+ * 任何读失败都退回 cordis.patch.yml 的 config + core.DEFAULTS,绝不拖垮 DSH 启动。
15
16
  * - apply() 内任何异常只写 warn 日志,绝不向上抛。
16
17
  * - 路由注册统一挂在 ctx.effect 上并返回 dispose,卸载时干净摘除。
17
18
  *
18
19
  * 纯业务口径(工资公式、节假日判定)全在 src/core.mjs,宿主侧只做「存取配置」。
19
20
  */
20
- import { readFileSync } from 'node:fs';
21
+ import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
22
+ import { homedir } from 'node:os';
23
+ import { dirname, join } from 'node:path';
21
24
  import { DEFAULTS, normalizeConfig, validateConfig } from '../src/core.mjs';
22
25
  import { HOLIDAY_SOURCE } from '../src/holidays.mjs';
23
26
 
@@ -35,8 +38,8 @@ export const name = 'niuma';
35
38
 
36
39
  /**
37
40
  * 依赖的宿主服务。
38
- * 只声明 webServer(插件唯一必需出口);settings 是可选依赖,用 ctx.inject 探测,
39
- * 不写进 inject 数组 —— 否则缺 settings 的组合里插件会直接加载不起来。
41
+ * 只声明 webServer(插件唯一必需出口)。配置持久化走自己的 JSON 文件,
42
+ * 不依赖 DSH 的 settings 服务(desktop 0.2.1-rc.2 里该服务缺席 → 写接口会永远 400)。
40
43
  */
41
44
  export const inject = ['webServer'];
42
45
 
@@ -44,44 +47,19 @@ const NAMESPACE = name;
44
47
  const ROUTE_BASE = `/${NAMESPACE}`;
45
48
  /** POST /niuma/settings 的请求体上限(64KB) */
46
49
  const MAX_BODY_BYTES = 64 * 1024;
47
- /** 与 core.mjs 的校验口径保持一致(月薪上限) */
48
- const SALARY_MAX = 10_000_000;
49
50
 
50
- /* ------------------------------------------------------------------ *
51
- * 设置 schema(schemastery):动态 import,失败降级为 null
52
- * ------------------------------------------------------------------ */
53
-
54
- let schemaPromise = null;
55
-
56
- /** 构造设置 schema;schemastery 不可用时返回 null(不抛错)。 */
57
- export async function loadConfigSchema() {
58
- try {
59
- const { default: z } = await import('@deepseek-ai/schemastery');
60
- // 注意:schemastery 的标量没有隐式默认值,字段缺省时会被对象丢掉 → 必须逐个 .default()
61
- return z.object({
62
- monthlySalary: z.number().min(0).max(SALARY_MAX)
63
- .default(DEFAULTS.monthlySalary)
64
- .description('实收月薪(元/月,税后到手;>0,上限 10,000,000)'),
65
- workStart: z.string().default(DEFAULTS.workStart).description('上班时间(HH:mm)'),
66
- workEnd: z.string().default(DEFAULTS.workEnd).description('下班时间(HH:mm,必须晚于上班时间,暂不支持跨夜班)'),
67
- lunchEnabled: z.boolean().default(DEFAULTS.lunchEnabled).description('是否启用午休'),
68
- lunchStart: z.string().default(DEFAULTS.lunchStart).description('午休开始时间(HH:mm,启用午休时必填)'),
69
- lunchEnd: z.string().default(DEFAULTS.lunchEnd).description('午休结束时间(HH:mm,必须晚于午休开始且不晚于下班)'),
70
- lunchPaid: z.boolean().default(DEFAULTS.lunchPaid).description('午休是否算工时(算工时则日有效工时更长、时薪更低)'),
71
- holidays: z.array(z.string()).default([...DEFAULTS.holidays])
72
- .description('法定节假日(YYYY-MM-DD,一行一个)。放假日期不算工作日,日工资会相应变高'),
73
- extraWorkdays: z.array(z.string()).default([...DEFAULTS.extraWorkdays])
74
- .description('调休上班日(YYYY-MM-DD,一行一个)。周末补班日期算工作日'),
75
- });
76
- } catch {
77
- return null; // 可选依赖缺失:退化为「只读默认值 + 写接口 400」
78
- }
79
- }
80
-
81
- /** 单例 schema promise:避免每次 apply 都重新 import。 */
82
- function configSchema() {
83
- if (!schemaPromise) schemaPromise = loadConfigSchema();
84
- return schemaPromise;
51
+ /**
52
+ * 配置文件的解析路径(纯函数,导出供烟测/文档):
53
+ * NIUMA_CONFIG_PATH → $DSH_HOME/niuma/config.json → ~/.dsh/niuma/config.json
54
+ * 只写自己目录,绝不碰 profile 的 package.json / lockfile。
55
+ * @param {object} [env]
56
+ * @returns {string}
57
+ */
58
+ export function resolveConfigPath(env = process.env) {
59
+ const override = typeof env?.NIUMA_CONFIG_PATH === 'string' ? env.NIUMA_CONFIG_PATH.trim() : '';
60
+ if (override) return override;
61
+ const home = typeof env?.DSH_HOME === 'string' && env.DSH_HOME.trim() ? env.DSH_HOME.trim() : join(homedir(), '.dsh');
62
+ return join(home, 'niuma', 'config.json');
85
63
  }
86
64
 
87
65
  /* ------------------------------------------------------------------ *
@@ -182,84 +160,41 @@ export async function apply(ctx, config = {}) {
182
160
  const baseConfig = config && typeof config === 'object' && !Array.isArray(config) ? config : {};
183
161
  const mountedAt = new Date().toISOString();
184
162
 
185
- // settings 服务可选:register 成功后才会有 scope / settingsService
186
- let scope = null;
187
- let settingsService = null;
163
+ /** 配置文件路径:NIUMA_CONFIG_PATH(测试/高级用户)→ $DSH_HOME/niuma/config.json → ~/.dsh/niuma/config.json。 */
164
+ const configPath = resolveConfigPath(process.env);
188
165
 
189
- /**
190
- * 当前生效配置 = core.DEFAULTS ← 组合配置 ← 用户设置(后者覆盖前者)。
191
- * 永远返回补全 + 规范化后的完整配置,视图侧不用再自己 merge。
192
- */
193
- const currentConfig = () => {
194
- let stored = {};
166
+ /** 读已持久化的配置;文件不存在/坏 JSON 都退回空对象(用默认值继续跑,绝不抛)。 */
167
+ const readStoredConfig = () => {
195
168
  try {
196
- const value = scope?.get?.();
197
- if (value && typeof value === 'object' && !Array.isArray(value)) stored = value;
169
+ const raw = readFileSync(configPath, 'utf8');
170
+ const parsed = JSON.parse(raw);
171
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
198
172
  } catch (error) {
199
- logger?.warn?.(`niuma: settings read failed: ${error?.message ?? error}`);
173
+ if (error?.code !== 'ENOENT') logger?.warn?.(`niuma: settings read failed: ${error?.message ?? error}`);
174
+ return {};
200
175
  }
201
- return normalizeConfig({ ...DEFAULTS, ...baseConfig, ...stored }).config;
202
176
  };
203
177
 
204
- /** 把校验后的完整配置写回 DSH 设置;返回是否真的持久化成功。 */
205
- const persistConfig = async (next) => {
206
- if (scope && typeof scope.update === 'function') {
207
- await scope.update(next);
208
- return true;
209
- }
210
- if (scope && typeof scope.replace === 'function') {
211
- await scope.replace(next);
212
- return true;
213
- }
214
- if (settingsService && typeof settingsService.update === 'function') {
215
- await settingsService.update(NAMESPACE, next);
216
- return true;
217
- }
218
- return false;
219
- };
178
+ /**
179
+ * 当前生效配置 = core.DEFAULTS ← 组合配置 ← 已保存的用户配置(后者覆盖前者)。
180
+ * 永远返回补全 + 规范化后的完整配置,视图侧不用再自己 merge。
181
+ */
182
+ const currentConfig = () => normalizeConfig({ ...DEFAULTS, ...baseConfig, ...readStoredConfig() }).config;
220
183
 
221
- // ---- 设置注册:schemastery 动态 import + 失败静默降级 ----
222
- const registerSettings = (settingsCtx) => {
223
- configSchema()
224
- .then((Schema) => {
225
- if (!Schema) {
226
- logger?.warn?.('niuma: schemastery unavailable, settings namespace skipped');
227
- return;
228
- }
229
- try {
230
- const registered = settingsCtx.settings?.register?.(NAMESPACE, Schema, { base: baseConfig });
231
- if (!registered) return;
232
- scope = registered;
233
- settingsService = settingsCtx.settings ?? null;
234
- try {
235
- // watch 只是为了与设置页联动(当前版本没有需要失效的缓存),失败不影响主流程
236
- if (typeof settingsCtx.effect === 'function') {
237
- settingsCtx.effect(() => scope?.watch?.(() => {}), 'niuma: settings watch');
238
- }
239
- } catch (error) {
240
- logger?.warn?.(`niuma: settings watch failed: ${error?.message ?? error}`);
241
- }
242
- logger?.info?.('niuma: settings namespace registered');
243
- } catch (error) {
244
- logger?.warn?.(`niuma: settings namespace unavailable: ${error?.message ?? error}`);
245
- }
246
- })
247
- .catch((error) => {
248
- logger?.warn?.(`niuma: settings schema load failed: ${error?.message ?? error}`);
249
- });
184
+ /**
185
+ * 把校验后的完整配置写到自己的 JSON 文件(原子写)。
186
+ * 失败抛错(写接口返回 400),绝不静默吞掉。
187
+ */
188
+ const persistConfig = async (next) => {
189
+ const dir = dirname(configPath);
190
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
191
+ const tmp = `${configPath}.tmp-${process.pid}`;
192
+ writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
193
+ renameSync(tmp, configPath);
194
+ try { chmodSync(configPath, 0o600); } catch { /* 权限收紧失败不影响内容 */ }
195
+ return true;
250
196
  };
251
197
 
252
- try {
253
- if (typeof ctx.inject === 'function') {
254
- ctx.inject(['settings'], registerSettings);
255
- } else if (ctx.settings) {
256
- // 极端情况下没有 ctx.inject(老内核/测试桩):直接尝试注册一次,失败也静默
257
- registerSettings(ctx);
258
- }
259
- } catch (error) {
260
- logger?.warn?.(`niuma: settings injection failed: ${error?.message ?? error}`);
261
- }
262
-
263
198
  // ---- 路由守卫:webServer 自身不做鉴权,复用 connection 的 Host/Origin 围栏 ----
264
199
  const guarded = (handler) => async (request, response) => {
265
200
  let rejection;
@@ -345,18 +280,13 @@ export async function apply(ctx, config = {}) {
345
280
  return;
346
281
  }
347
282
 
348
- let persisted = false;
349
283
  try {
350
- persisted = await persistConfig(next);
284
+ await persistConfig(next);
351
285
  } catch (error) {
352
286
  logger?.warn?.(`niuma: settings persist failed: ${error?.stack ?? error}`);
353
287
  sendJson(response, 400, { ok: false, error: `写入设置失败:${error?.message ?? error}` });
354
288
  return;
355
289
  }
356
- if (!persisted) {
357
- sendJson(response, 400, { ok: false, error: '当前环境不支持写入设置(settings 服务不可用)' });
358
- return;
359
- }
360
290
  sendJson(response, 200, { ok: true, data: { config: next } });
361
291
  };
362
292
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-niuma",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "牛马加速器 — DSH 工资换算实时计数器:按实收月薪、上下班时间、午休(可设是否算工时)、法定节假日自动折算秒/分/时/日/周/月工资,工资每秒滚动增长。Community salary ticker plugin for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -16,7 +16,7 @@
16
16
  "lib",
17
17
  "src",
18
18
  "cordis.patch.yml",
19
- "docs/*.png",
19
+ "docs/preview-light-dark.png",
20
20
  "README.md",
21
21
  "README.en.md",
22
22
  "CHANGELOG.md",
@@ -68,19 +68,14 @@
68
68
  },
69
69
  "dependencies": {},
70
70
  "peerDependencies": {
71
- "@deepseek-ai/cordis": "^4.0.2",
72
- "@deepseek-ai/schemastery": "^3.18.2"
71
+ "@deepseek-ai/cordis": "^4.0.2"
73
72
  },
74
73
  "peerDependenciesMeta": {
75
74
  "@deepseek-ai/cordis": {
76
75
  "optional": true
77
- },
78
- "@deepseek-ai/schemastery": {
79
- "optional": true
80
76
  }
81
77
  },
82
78
  "devDependencies": {
83
- "@deepseek-ai/schemastery": "3.18.4",
84
79
  "esbuild": "^0.28.2"
85
80
  },
86
81
  "scripts": {