dsh-plugin-update 0.1.2 → 0.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/dist/store.js CHANGED
@@ -3,7 +3,11 @@ import { createHash, randomUUID } from "node:crypto";
3
3
  import { mkdir, open, readFile, rename, realpath, stat, unlink, writeFile } from "node:fs/promises";
4
4
  import { dirname, isAbsolute, join, resolve } from "node:path";
5
5
  import { installRecipe } from "./commands.js";
6
- import { BACKUP_FILE, LEGACY_PLUGIN_ID, LOCK_FILE, STATE_FILE } from "./config.js";
6
+ import { BACKUP_FILE, LEGACY_PLUGIN_ID, LOCK_FILE, SKIPPED_FILE, STATE_FILE } from "./config.js";
7
+ import { emptyBatchSession, normalizeBatchSession } from "./batch.js";
8
+ import { normalizeQueueState } from "./queue.js";
9
+ import { validReleaseVersion } from "./service.js";
10
+ import { sanitizeDetail } from "./redaction.js";
7
11
  const LEGACY_SEGMENT = "updates";
8
12
  const LEGACY_DIR_PLUGIN = LEGACY_PLUGIN_ID;
9
13
  const JOB_STATES = ["installing", "verifying", "restart-required", "completed", "failed", "interrupted"];
@@ -173,6 +177,228 @@ function createUpdateDiskPorts(homeDir, pluginId, profileDir, deps = {}) {
173
177
  }
174
178
  return { paths, legacyPaths, readJob, writeJob, tryAcquireLock, releaseLock, backupJob };
175
179
  }
180
+ const QUEUE_DIR_SEGMENT = "update-queue";
181
+ const QUEUE_FILE = "queue.json";
182
+ const QUEUE_LOCK_FILE = "global.lock";
183
+ const QUEUE_LOCK_STALE_MS = 15 * 6e4;
184
+ function queuePathsForUpdate(homeDir, profileDir) {
185
+ if (typeof homeDir !== "string" || !homeDir || typeof profileDir !== "string" || !profileDir) return null;
186
+ const directory = join(homeDir, QUEUE_DIR_SEGMENT, shortHash(profileDir));
187
+ return { directory, file: join(directory, QUEUE_FILE), lock: join(directory, QUEUE_LOCK_FILE) };
188
+ }
189
+ function queueFail() {
190
+ return Object.assign(new Error("queue-unavailable"), { code: "queue-unavailable" });
191
+ }
192
+ function createUpdateQueuePorts(homeDir, profileDir, deps = {}) {
193
+ const paths = queuePathsForUpdate(homeDir, profileDir);
194
+ const readText = deps.readFileImpl ?? ((filename, encoding) => readFile(filename, encoding));
195
+ const statFile = deps.statImpl ?? ((filename) => stat(filename));
196
+ const now = deps.nowImpl ?? Date.now;
197
+ async function readQueue() {
198
+ if (!paths) throw queueFail();
199
+ try {
200
+ if ((await statFile(paths.file)).size > 10 * 1024 * 1024) throw queueFail();
201
+ return normalizeQueueState(JSON.parse(await readText(paths.file, "utf8")));
202
+ } catch (error) {
203
+ if (error && error.code === "ENOENT") {
204
+ return normalizeQueueState(null);
205
+ }
206
+ throw error && error.code ? error : queueFail();
207
+ }
208
+ }
209
+ async function writeQueue(state) {
210
+ if (!paths) throw queueFail();
211
+ try {
212
+ await mkdir(paths.directory, { recursive: true, mode: 448 });
213
+ const temporary = `${paths.file}.${randomUUID()}.tmp`;
214
+ try {
215
+ await writeFile(temporary, `${JSON.stringify(normalizeQueueState(state), null, 2)}
216
+ `, { mode: 384, flag: "wx" });
217
+ await rename(temporary, paths.file);
218
+ } catch {
219
+ throw queueFail();
220
+ } finally {
221
+ await unlink(temporary).catch(() => {
222
+ });
223
+ }
224
+ } catch (error) {
225
+ throw error && error.code ? error : queueFail();
226
+ }
227
+ }
228
+ async function readLockRaw() {
229
+ if (!paths) return null;
230
+ try {
231
+ if ((await statFile(paths.lock)).size > 1024 * 1024) return null;
232
+ return JSON.parse(await readText(paths.lock, "utf8"));
233
+ } catch {
234
+ return null;
235
+ }
236
+ }
237
+ async function tryAcquireGlobalLock(lockId, pluginId, opts = {}) {
238
+ if (!paths) return false;
239
+ if (typeof lockId !== "string" || !lockId || typeof pluginId !== "string" || !pluginId) return false;
240
+ const timeoutMs = typeof opts.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0 ? opts.timeoutMs : QUEUE_LOCK_STALE_MS;
241
+ try {
242
+ await mkdir(paths.directory, { recursive: true, mode: 448 });
243
+ const handle = await open(paths.lock, "wx", 384);
244
+ try {
245
+ await handle.writeFile(JSON.stringify({ id: lockId, pluginId, pid: process.pid, startedAt: now() }));
246
+ } finally {
247
+ await handle.close();
248
+ }
249
+ return true;
250
+ } catch (error) {
251
+ if (!error || error.code !== "EEXIST") return false;
252
+ try {
253
+ const current = await readLockRaw();
254
+ const startedAt = current && typeof current["startedAt"] === "number" ? current["startedAt"] : 0;
255
+ if (!current || now() - startedAt < timeoutMs) return false;
256
+ await unlink(paths.lock).catch(() => {
257
+ });
258
+ const handle = await open(paths.lock, "wx", 384);
259
+ try {
260
+ await handle.writeFile(JSON.stringify({ id: lockId, pluginId, pid: process.pid, startedAt: now() }));
261
+ } finally {
262
+ await handle.close();
263
+ }
264
+ return true;
265
+ } catch {
266
+ return false;
267
+ }
268
+ }
269
+ }
270
+ async function releaseGlobalLock(lockId) {
271
+ if (!paths) return;
272
+ try {
273
+ const current = await readLockRaw();
274
+ if (current && current.id === lockId) await unlink(paths.lock).catch(() => {
275
+ });
276
+ } catch {
277
+ }
278
+ }
279
+ return { paths, readQueue, writeQueue, tryAcquireGlobalLock, releaseGlobalLock };
280
+ }
281
+ const BATCH_FILE = "batch.json";
282
+ const BATCH_FILE_MAX_BYTES = 1024 * 1024;
283
+ function batchPathsForUpdate(homeDir, profileDir) {
284
+ const queue = queuePathsForUpdate(homeDir, profileDir);
285
+ if (!queue) return null;
286
+ return { directory: queue.directory, file: join(queue.directory, BATCH_FILE) };
287
+ }
288
+ function batchFail() {
289
+ return Object.assign(new Error("batch-unavailable"), { code: "batch-unavailable" });
290
+ }
291
+ function createBatchDiskPorts(homeDir, profileDir, deps = {}) {
292
+ const paths = batchPathsForUpdate(homeDir, profileDir);
293
+ const readText = deps.readFileImpl ?? ((filename, encoding) => readFile(filename, encoding));
294
+ const statFile = deps.statImpl ?? ((filename) => stat(filename));
295
+ async function readBatch() {
296
+ if (!paths) return emptyBatchSession();
297
+ try {
298
+ if ((await statFile(paths.file)).size > BATCH_FILE_MAX_BYTES) return emptyBatchSession();
299
+ return normalizeBatchSession(JSON.parse(await readText(paths.file, "utf8")));
300
+ } catch {
301
+ return emptyBatchSession();
302
+ }
303
+ }
304
+ async function writeBatch(session) {
305
+ if (!paths) throw batchFail();
306
+ const normalized = normalizeBatchSession(session);
307
+ try {
308
+ await mkdir(paths.directory, { recursive: true, mode: 448 });
309
+ const temporary = `${paths.file}.${randomUUID()}.tmp`;
310
+ try {
311
+ await writeFile(temporary, `${JSON.stringify(normalized, null, 2)}
312
+ `, { mode: 384, flag: "wx" });
313
+ await rename(temporary, paths.file);
314
+ } catch {
315
+ throw batchFail();
316
+ } finally {
317
+ await unlink(temporary).catch(() => {
318
+ });
319
+ }
320
+ } catch (error) {
321
+ throw error && error.code ? error : batchFail();
322
+ }
323
+ }
324
+ async function clearBatch() {
325
+ if (!paths) return;
326
+ await writeBatch(emptyBatchSession());
327
+ }
328
+ return { paths, readBatch, writeBatch, clearBatch };
329
+ }
330
+ const MAX_SKIPPED_ENTRIES = 50;
331
+ function skipKey(pluginId, version) {
332
+ return `${String(pluginId)}@${String(version)}`;
333
+ }
334
+ function normalizeSkipped(raw) {
335
+ if (!raw || typeof raw !== "object") return [];
336
+ const list = raw.skipped;
337
+ if (!Array.isArray(list)) return [];
338
+ const seen = /* @__PURE__ */ new Set();
339
+ const out = [];
340
+ for (const item of list) {
341
+ if (!item || typeof item !== "object") continue;
342
+ const version = item.version;
343
+ const skippedAt = item.skippedAt;
344
+ if (!validReleaseVersion(version) || seen.has(version)) continue;
345
+ seen.add(version);
346
+ out.push({ version, skippedAt: typeof skippedAt === "number" && Number.isFinite(skippedAt) ? skippedAt : 0 });
347
+ }
348
+ return out.slice(0, MAX_SKIPPED_ENTRIES);
349
+ }
350
+ function isVersionSkipped(skipped, version) {
351
+ if (!validReleaseVersion(version)) return false;
352
+ return normalizeSkipped({ skipped }).some((entry) => entry.version === version);
353
+ }
354
+ function addSkipped(skipped, version, now) {
355
+ if (!validReleaseVersion(version)) throw fail("install-failed");
356
+ const at = typeof now === "number" && Number.isFinite(now) ? now : Date.now();
357
+ const rest = normalizeSkipped({ skipped }).filter((entry) => entry.version !== version);
358
+ return [{ version, skippedAt: at }, ...rest].slice(0, MAX_SKIPPED_ENTRIES);
359
+ }
360
+ function clearSkipped(skipped, version) {
361
+ const base = normalizeSkipped({ skipped });
362
+ if (version === void 0) return [];
363
+ return base.filter((entry) => entry.version !== version);
364
+ }
365
+ function createSkipDiskPorts(homeDir, pluginId, profileDir, deps = {}) {
366
+ const paths = pathsForUpdate(homeDir, pluginId, profileDir);
367
+ const file = paths ? join(paths.directory, SKIPPED_FILE) : null;
368
+ const readText = deps.readFileImpl ?? ((filename, encoding) => readFile(filename, encoding));
369
+ const statFile = deps.statImpl ?? ((filename) => stat(filename));
370
+ async function readSkipped() {
371
+ if (!file) return [];
372
+ try {
373
+ if ((await statFile(file)).size > 64 * 1024) return [];
374
+ return normalizeSkipped(JSON.parse(await readText(file, "utf8")));
375
+ } catch {
376
+ return [];
377
+ }
378
+ }
379
+ async function writeSkipped(skipped) {
380
+ if (!file || !paths) throw fail("install-failed");
381
+ const normalized = normalizeSkipped({ skipped });
382
+ await mkdir(paths.directory, { recursive: true, mode: 448 });
383
+ const temporary = `${file}.${randomUUID()}.tmp`;
384
+ try {
385
+ await writeFile(temporary, `${JSON.stringify({ skipped: normalized }, null, 2)}
386
+ `, { mode: 384, flag: "wx" });
387
+ await rename(temporary, file);
388
+ } catch {
389
+ throw fail("install-failed");
390
+ } finally {
391
+ await unlink(temporary).catch(() => {
392
+ });
393
+ }
394
+ }
395
+ async function clearSkippedVersion(version) {
396
+ const next = clearSkipped(await readSkipped(), version);
397
+ await writeSkipped(next);
398
+ return next;
399
+ }
400
+ return { file, readSkipped, writeSkipped, clearSkippedVersion };
401
+ }
176
402
  const CLI_PACKAGE_NAME = "@deepseek-ai/dsh";
177
403
  const INSTALL_STDIO = { stdin: "ignore", stdout: { maxBytes: 64 * 1024 }, stderr: { maxBytes: 64 * 1024 } };
178
404
  const TERMINATION_GRACE_MS = 3e3;
@@ -294,19 +520,11 @@ async function runDesktopService(recipe, parts) {
294
520
  if (code !== 0) throw withExit(fail("install-failed"), code);
295
521
  return code;
296
522
  }
297
- const DETAIL_MAX = 300;
298
523
  const MANAGER_OK = ["applied", "restart-required", "overridden"];
299
524
  function withDetail(error, detail) {
300
525
  if (detail) error.detail = detail;
301
526
  return error;
302
527
  }
303
- const ABSOLUTE_PATH_RE = /[A-Za-z]:\\[^\s"']*|(^|\s)\/[^\s"']+/g;
304
- function sanitizeDetail(text) {
305
- const flat = String(text ?? "").replace(/\s+/g, " ").trim();
306
- if (!flat) return "";
307
- const noPath = flat.replace(ABSOLUTE_PATH_RE, (_whole, lead) => `${lead || ""}<\u8DEF\u5F84>`);
308
- return noPath.length > DETAIL_MAX ? `${noPath.slice(0, DETAIL_MAX)}\u2026` : noPath;
309
- }
310
528
  function detailText(error) {
311
529
  if (typeof error === "string") return sanitizeDetail(error);
312
530
  if (error instanceof Error) return sanitizeDetail(error.message);
@@ -315,7 +533,12 @@ function detailText(error) {
315
533
  const code = typeof shape.code === "string" ? shape.code : "";
316
534
  const said = typeof shape.diagnostic === "string" ? shape.diagnostic : typeof shape.message === "string" ? shape.message : "";
317
535
  const line = [code, said].filter(Boolean).join(": ");
318
- if (line) return sanitizeDetail(line);
536
+ if (line) {
537
+ const clean = sanitizeDetail(line);
538
+ if (clean) return clean;
539
+ if (code) return sanitizeDetail(code);
540
+ return "";
541
+ }
319
542
  try {
320
543
  return sanitizeDetail(JSON.stringify(error));
321
544
  } catch {
@@ -460,7 +683,6 @@ function createUpdateExecutor(parts = {}) {
460
683
  emitInstall(parts, recipe, false, error?.exitCode ?? exitCode, Date.now() - startedAt);
461
684
  const detail = error?.detail;
462
685
  throw Object.assign(fail("install-failed"), {
463
- debug: String(error?.stack || error),
464
686
  ...typeof detail === "string" && detail ? { detail } : {}
465
687
  });
466
688
  }
@@ -468,10 +690,27 @@ function createUpdateExecutor(parts = {}) {
468
690
  }
469
691
  const __executorInternals = { INSTALL_STDIO, TERMINATION_GRACE_MS, CLI_PACKAGE_NAME };
470
692
  export {
693
+ BATCH_FILE,
694
+ BATCH_FILE_MAX_BYTES,
695
+ MAX_SKIPPED_ENTRIES,
696
+ QUEUE_DIR_SEGMENT,
697
+ QUEUE_FILE,
698
+ QUEUE_LOCK_FILE,
699
+ QUEUE_LOCK_STALE_MS,
471
700
  __executorInternals,
701
+ addSkipped,
702
+ batchPathsForUpdate,
703
+ clearSkipped,
704
+ createBatchDiskPorts,
705
+ createSkipDiskPorts,
472
706
  createUpdateDiskPorts,
473
707
  createUpdateExecutor,
708
+ createUpdateQueuePorts,
709
+ isVersionSkipped,
474
710
  legacyPathsForUpdate,
711
+ normalizeSkipped,
475
712
  pathsForUpdate,
476
- resolveCliEntry
713
+ queuePathsForUpdate,
714
+ resolveCliEntry,
715
+ skipKey
477
716
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-update",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "可复用的更新系统装成的 npm 包:任何 DSH 插件照文档能集成检查更新等功能(地图 #579)",
5
5
  "license": "MIT",
6
6
  "author": "王辰浩",
@@ -12,8 +12,32 @@
12
12
  "node": ">=22"
13
13
  },
14
14
  "type": "module",
15
+ "types": "./dist/host.d.ts",
15
16
  "exports": {
16
- ".": "./dist/host.js",
17
+ ".": {
18
+ "types": "./dist/host.d.ts",
19
+ "default": "./dist/host.js"
20
+ },
21
+ "./panel": {
22
+ "types": "./dist/panel.d.ts",
23
+ "default": "./dist/panel.js"
24
+ },
25
+ "./client": {
26
+ "types": "./dist/client.d.ts",
27
+ "default": "./dist/client.js"
28
+ },
29
+ "./batch": {
30
+ "types": "./dist/host-batch.d.ts",
31
+ "default": "./dist/host-batch.js"
32
+ },
33
+ "./panel-batch": {
34
+ "types": "./dist/panel-batch.d.ts",
35
+ "default": "./dist/panel-batch.js"
36
+ },
37
+ "./entry": {
38
+ "types": "./dist/entry.d.ts",
39
+ "default": "./dist/entry.js"
40
+ },
17
41
  "./package.json": "./package.json"
18
42
  },
19
43
  "files": [
@@ -21,6 +45,7 @@
21
45
  "derive-client-values.mjs",
22
46
  "event-list.template.json",
23
47
  "README.md",
48
+ "CHANGELOG.md",
24
49
  "LICENSE"
25
50
  ],
26
51
  "publishConfig": {