dsh-plugin-update 0.2.0 → 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/CHANGELOG.md +49 -0
- package/README.md +483 -279
- package/dist/batch-run.d.ts +80 -0
- package/dist/batch-run.js +86 -0
- package/dist/batch.d.ts +111 -0
- package/dist/batch.js +183 -0
- package/dist/changelog-io.d.ts +56 -0
- package/dist/changelog-io.js +258 -0
- package/dist/changelog.d.ts +96 -0
- package/dist/changelog.js +287 -0
- package/dist/client.d.ts +16 -0
- package/dist/client.js +75 -1
- package/dist/commands.d.ts +59 -0
- package/dist/commands.js +60 -4
- package/dist/config.d.ts +48 -0
- package/dist/config.js +9 -1
- package/dist/diag.d.ts +125 -0
- package/dist/diag.js +243 -0
- package/dist/entry.d.ts +60 -0
- package/dist/entry.js +305 -0
- package/dist/gate.d.ts +35 -0
- package/dist/host-batch.d.ts +113 -0
- package/dist/host-batch.js +778 -0
- package/dist/host.d.ts +129 -0
- package/dist/host.js +479 -41
- package/dist/panel-batch.d.ts +151 -0
- package/dist/panel-batch.js +927 -0
- package/dist/panel.d.ts +256 -0
- package/dist/panel.js +1300 -0
- package/dist/ports.d.ts +208 -0
- package/dist/queue.d.ts +109 -0
- package/dist/queue.js +182 -0
- package/dist/reader.d.ts +84 -0
- package/dist/reader.js +6 -4
- package/dist/redaction.d.ts +120 -0
- package/dist/redaction.js +205 -0
- package/dist/service.d.ts +72 -0
- package/dist/service.js +100 -6
- package/dist/store.d.ts +229 -0
- package/dist/store.js +251 -12
- package/package.json +27 -2
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/store.ts —— 更新任务的落盘与执行跑腿。
|
|
3
|
+
*
|
|
4
|
+
* 由 src/host/updateStore.js 改写拎入:只跑腿不决策。相对旧实现的增量(规格 #591):
|
|
5
|
+
* 1. 状态目录按插件标识派生(第 3 条):join(家目录, 'updates', 插件标识, 使用范围短指纹),
|
|
6
|
+
* 三文件名保持 state.json、install.lock、before.json 不变。
|
|
7
|
+
* 2. 默认旧路径冻结(第 14 条):插件标识取旧值 dsh-mattpocock-skills-deck 时路径与旧原文
|
|
8
|
+
* 一字不差——下面的旧字面是永久冻结特例,注释永不删除(动默认即破冰)。
|
|
9
|
+
* 3. 读走双读、写只写新(第 7 条):先读新路径,没有再回退读旧路径;写只写新路径,
|
|
10
|
+
* 旧路径只读不写永久留作只读影子。旧文件不动,清理另票再议。
|
|
11
|
+
* 4. 三处注入默认现状(第 4 条):目标包名、官方源、安装时限经零件注入,默认值等于现状。
|
|
12
|
+
* 5. 安装执行日志带必填插件标识(第 8、13 条):update.install.exec 加完标识后 5 键为基线。
|
|
13
|
+
*
|
|
14
|
+
* 安装执行按核心给的配方跑三条路由之一(第三方 Desktop 走桌面服务、官方桌面版交给宿主
|
|
15
|
+
* 进程内的插件管理器、普通 DSH 自己起进程),
|
|
16
|
+
* 本文件不按操作系统分支,也不拼 shell。决策顺序全在更新核心。测试一律用假零件,
|
|
17
|
+
* 不真写盘真跑命令(经可选的文件读写函数注入,默认走真文件系统)。
|
|
18
|
+
*/
|
|
19
|
+
import { type BatchSession } from './batch.js';
|
|
20
|
+
import { type UpdateQueueState } from './queue.js';
|
|
21
|
+
import type { EnvironmentKind, UpdateJob } from './ports.js';
|
|
22
|
+
export interface UpdatePaths {
|
|
23
|
+
directory: string;
|
|
24
|
+
state: string;
|
|
25
|
+
lock: string;
|
|
26
|
+
backup: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* 状态目录:家目录下按插件标识派生,短指纹只做同一性判断,不记原文。
|
|
30
|
+
* 插件标识取旧值时与旧原文一字不差(旧默认路径冻结,验收项)。
|
|
31
|
+
*/
|
|
32
|
+
export declare function pathsForUpdate(homeDir: string, pluginId: string, profileDir: string): UpdatePaths | null;
|
|
33
|
+
/** 旧只读影子目录(规格 #591 第 7 条):只读不写,永久留作回退读取用。 */
|
|
34
|
+
export declare function legacyPathsForUpdate(homeDir: string, profileDir: string): UpdatePaths | null;
|
|
35
|
+
export interface DiskPortsDeps {
|
|
36
|
+
readFileImpl?: (filename: string, encoding: string) => Promise<string>;
|
|
37
|
+
statImpl?: (filename: string) => Promise<{
|
|
38
|
+
size: number;
|
|
39
|
+
}>;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* 建任务存取与锁:调用方按当前插件标识建一份,转交核心当零件。
|
|
43
|
+
* 读走双读(先新后旧),写只写新(旧影子只读不写)。
|
|
44
|
+
*/
|
|
45
|
+
export declare function createUpdateDiskPorts(homeDir: string, pluginId: string, profileDir: string, deps?: DiskPortsDeps): {
|
|
46
|
+
paths: UpdatePaths | null;
|
|
47
|
+
legacyPaths: UpdatePaths | null;
|
|
48
|
+
readJob: () => Promise<UpdateJob | null>;
|
|
49
|
+
writeJob: (job: UpdateJob | null) => Promise<void>;
|
|
50
|
+
tryAcquireLock: (lockId: string) => Promise<boolean>;
|
|
51
|
+
releaseLock: (lockId: string) => Promise<void>;
|
|
52
|
+
backupJob: (job: UpdateJob) => Promise<void>;
|
|
53
|
+
};
|
|
54
|
+
/** 跨插件队列的共享落盘(#15,新增强制串行的归属与持久化,不碰按插件隔离的旧树)。
|
|
55
|
+
*
|
|
56
|
+
* 归属:按使用范围共享——同一 (homeDir, profileDir) 的全部插件共用同一目录,
|
|
57
|
+
* 不同使用范围各用各的、可并行。父目录取 `update-queue`(与 `updates` 平级),
|
|
58
|
+
* 任何插件标识都撞不上(插件标识只能决定 `updates` 下的子目录名)。
|
|
59
|
+
* 文件:`queue.json`(owner + waiting,快照式整写),`global.lock`(执行互斥,
|
|
60
|
+
* 抢到才许装,一次只装一个)。
|
|
61
|
+
*/
|
|
62
|
+
export declare const QUEUE_DIR_SEGMENT = "update-queue";
|
|
63
|
+
export declare const QUEUE_FILE = "queue.json";
|
|
64
|
+
export declare const QUEUE_LOCK_FILE = "global.lock";
|
|
65
|
+
/** 全局锁陈旧回收默认口径:15 分钟(与安装时限同口径,调用方可按注入覆盖)。 */
|
|
66
|
+
export declare const QUEUE_LOCK_STALE_MS: number;
|
|
67
|
+
export interface UpdateQueuePaths {
|
|
68
|
+
directory: string;
|
|
69
|
+
file: string;
|
|
70
|
+
lock: string;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* 队列共享目录:家目录下按使用范围短指纹派生(短指纹算法与按插件隔离的旧树同一套,
|
|
74
|
+
* 只做同一性判断,不记原文)。入参非法返回 null,调用方按“无队列可用”放行安装,
|
|
75
|
+
* 绝不因队列自身故障挡住更新。
|
|
76
|
+
*/
|
|
77
|
+
export declare function queuePathsForUpdate(homeDir: string, profileDir: string): UpdateQueuePaths | null;
|
|
78
|
+
export interface QueuePortsDeps {
|
|
79
|
+
readFileImpl?: (filename: string, encoding: string) => Promise<string>;
|
|
80
|
+
statImpl?: (filename: string) => Promise<{
|
|
81
|
+
size: number;
|
|
82
|
+
}>;
|
|
83
|
+
nowImpl?: () => number;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* 建跨插件队列存取与全局锁:调用方按当前使用范围建一份(与插件标识无关),转交宿主入口组合进安装锁。
|
|
87
|
+
* 读走单读(队列文件只此一份,没有旧影子);写原子整写;锁走独占建文件,过期可回收一次。
|
|
88
|
+
*/
|
|
89
|
+
export declare function createUpdateQueuePorts(homeDir: string, profileDir: string, deps?: QueuePortsDeps): {
|
|
90
|
+
paths: UpdateQueuePaths | null;
|
|
91
|
+
readQueue: () => Promise<UpdateQueueState>;
|
|
92
|
+
writeQueue: (state: UpdateQueueState) => Promise<void>;
|
|
93
|
+
tryAcquireGlobalLock: (lockId: string, pluginId: string, opts?: {
|
|
94
|
+
timeoutMs?: number;
|
|
95
|
+
}) => Promise<boolean>;
|
|
96
|
+
releaseGlobalLock: (lockId: string) => Promise<void>;
|
|
97
|
+
};
|
|
98
|
+
/** 批量会话账本的共享落盘(#25,与队列同一目录:同一使用范围一份,跨插件共用)。
|
|
99
|
+
*
|
|
100
|
+
* 归属:按使用范围共享(与跨插件队列同一套目录派生:`update-queue/<使用范围短指纹>`),
|
|
101
|
+
* 不同使用范围各用各的。文件只有一份 `batch.json` —— 「这一轮批量更新走到哪了」是全范围唯一的一份,
|
|
102
|
+
* 与按插件隔离的 `updates/<插件标识>/...` 那棵树完全分开(父目录不同,任何插件标识都撞不上)。
|
|
103
|
+
* 纪律与队列同一套:读坏自愈为空会话(缺文件、超大、JSON 坏掉、读失败都回空,绝不因账本坏掉挡住更新);
|
|
104
|
+
* 写走原子整写(临时文件 + rename,写完删临时件);清空写一份空会话(不是删文件,避免与并发读打架)。
|
|
105
|
+
*/
|
|
106
|
+
export declare const BATCH_FILE = "batch.json";
|
|
107
|
+
/** 账本读入上限:1 MiB,超了当坏账本(正常一份几百字节)。 */
|
|
108
|
+
export declare const BATCH_FILE_MAX_BYTES: number;
|
|
109
|
+
export interface UpdateBatchPaths {
|
|
110
|
+
directory: string;
|
|
111
|
+
file: string;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* 批量账本路径:与队列同一目录(`update-queue/<短指纹>/batch.json`)。
|
|
115
|
+
* 入参非法返回 null,调用方按「账本不可落盘」处理(诚实说明,绝不猜目录)。
|
|
116
|
+
*/
|
|
117
|
+
export declare function batchPathsForUpdate(homeDir: string, profileDir: string): UpdateBatchPaths | null;
|
|
118
|
+
export interface BatchPortsDeps {
|
|
119
|
+
readFileImpl?: (filename: string, encoding: string) => Promise<string>;
|
|
120
|
+
statImpl?: (filename: string) => Promise<{
|
|
121
|
+
size: number;
|
|
122
|
+
}>;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* 建批量账本存取:调用方按当前使用范围建一份(与插件标识无关),转交批量宿主入口。
|
|
126
|
+
* 读走单读自愈为空会话;写原子整写;清空写空会话。路径算不出来时不抛错(读回空、写抛 batch-unavailable)。
|
|
127
|
+
*/
|
|
128
|
+
export declare function createBatchDiskPorts(homeDir: string, profileDir: string, deps?: BatchPortsDeps): {
|
|
129
|
+
paths: UpdateBatchPaths | null;
|
|
130
|
+
readBatch: () => Promise<BatchSession>;
|
|
131
|
+
writeBatch: (session: BatchSession) => Promise<void>;
|
|
132
|
+
clearBatch: () => Promise<void>;
|
|
133
|
+
};
|
|
134
|
+
/** 按插件 + 版本持久化的跳过记录(#16,与三冻结落盘名并存的第四文件,不碰旧树)。
|
|
135
|
+
*
|
|
136
|
+
* 键:跳过只按「插件标识 + 版本」记——目录已按插件标识隔离(pathsForUpdate),
|
|
137
|
+
* 文件内只记版本集合。新版本恒重新提醒(跳过是 dismissal 不是全局静音)。
|
|
138
|
+
* 重置入口(第一性原理结论,见 #16):已跳过版本留在同一更新横幅行展示为
|
|
139
|
+
* 「已跳过 X.Y.Z · 恢复」,点恢复即清掉该版本的跳过并重查;不藏进设置页。
|
|
140
|
+
* 文件:`skipped.json`(`{ skipped: [{ version, skippedAt }] }`,快照式整写,上限 50 条)。
|
|
141
|
+
*/
|
|
142
|
+
export interface SkippedVersionEntry {
|
|
143
|
+
version: string;
|
|
144
|
+
skippedAt: number;
|
|
145
|
+
}
|
|
146
|
+
/** 跳过文件上限:只留最近 50 条,避免无界增长(老条目自然淘汰)。 */
|
|
147
|
+
export declare const MAX_SKIPPED_ENTRIES = 50;
|
|
148
|
+
/** 跳过展示键:面板行展示与去重用(`插件标识@版本`)。 */
|
|
149
|
+
export declare function skipKey(pluginId: string, version: string): string;
|
|
150
|
+
/** 跳过记录归一化:只收发行合法版(stable + 预发布),非法条目丢弃(自愈不抛错)。 */
|
|
151
|
+
export declare function normalizeSkipped(raw: unknown): SkippedVersionEntry[];
|
|
152
|
+
/** 该版本是否已被跳过(版本须发行合法,否则为 false)。 */
|
|
153
|
+
export declare function isVersionSkipped(skipped: readonly SkippedVersionEntry[] | unknown, version: unknown): boolean;
|
|
154
|
+
/** 记一次跳过:同版本幂等(刷新 skippedAt),新版本追加并按上限裁剪。 */
|
|
155
|
+
export declare function addSkipped(skipped: readonly SkippedVersionEntry[] | unknown, version: string, now: number): SkippedVersionEntry[];
|
|
156
|
+
/** 重置跳过:给了版本只清该版本(面板「恢复」走这条),不给清全部。 */
|
|
157
|
+
export declare function clearSkipped(skipped: readonly SkippedVersionEntry[] | unknown, version?: string): SkippedVersionEntry[];
|
|
158
|
+
export interface SkipPortsDeps {
|
|
159
|
+
readFileImpl?: (filename: string, encoding: string) => Promise<string>;
|
|
160
|
+
statImpl?: (filename: string) => Promise<{
|
|
161
|
+
size: number;
|
|
162
|
+
}>;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* 建跳过存取:调用方按当前插件标识建一份(目录与任务落盘同一套,无旧影子)。
|
|
166
|
+
* 读坏自愈为空(缺文件、超大、JSON 坏掉都回 [],绝不因跳过文件挡住更新);
|
|
167
|
+
* 写走原子整写。
|
|
168
|
+
*/
|
|
169
|
+
export declare function createSkipDiskPorts(homeDir: string, pluginId: string, profileDir: string, deps?: SkipPortsDeps): {
|
|
170
|
+
file: string | null;
|
|
171
|
+
readSkipped: () => Promise<SkippedVersionEntry[]>;
|
|
172
|
+
writeSkipped: (skipped: readonly SkippedVersionEntry[] | unknown) => Promise<void>;
|
|
173
|
+
clearSkippedVersion: (version?: string) => Promise<SkippedVersionEntry[]>;
|
|
174
|
+
};
|
|
175
|
+
/**
|
|
176
|
+
* 从正在运行的入口反查 CLI 的 JS 入口:逐级向上找名字为 @deepseek-ai/dsh 的包,
|
|
177
|
+
* 且该文件必须正好是包声明的可执行入口(bin.dsh 或 bin 字符串)。对不上就返回 null,
|
|
178
|
+
* 绝不用 PATH 上的 `dsh` 命令名——否则可能装到别的使用范围或用错版本。
|
|
179
|
+
* 第二参只给测试注入假清单读取器与假路径解析用(默认都走真文件系统)。
|
|
180
|
+
*/
|
|
181
|
+
export declare function resolveCliEntry(argv1: string, deps?: {
|
|
182
|
+
readManifest?: (directory: string) => Promise<Record<string, unknown> | null>;
|
|
183
|
+
realpath?: (path: string) => Promise<string>;
|
|
184
|
+
}): Promise<string | null>;
|
|
185
|
+
export interface ExecutorParts {
|
|
186
|
+
profileName?: string | null | (() => string | null);
|
|
187
|
+
environmentKind?: EnvironmentKind | (() => EnvironmentKind);
|
|
188
|
+
profileDir?: string | (() => string);
|
|
189
|
+
subprocess?: unknown | (() => unknown);
|
|
190
|
+
desktopPnpm?: unknown | (() => unknown);
|
|
191
|
+
desktopProfiles?: unknown | (() => unknown);
|
|
192
|
+
/** 官方桌面版进程内的插件管理器(现取,不缓存跨代服务)。 */
|
|
193
|
+
pluginManager?: unknown | (() => unknown);
|
|
194
|
+
runtimeExecutable?: string | (() => string | undefined);
|
|
195
|
+
runtimeExecArgs?: string[] | (() => string[] | undefined);
|
|
196
|
+
cliEntry?: string | (() => string | undefined);
|
|
197
|
+
targetPackageName?: string | (() => string);
|
|
198
|
+
registryUrl?: string | (() => string);
|
|
199
|
+
installTimeoutMs?: number | (() => number);
|
|
200
|
+
pluginId?: string | (() => string);
|
|
201
|
+
log?: (level: string, event: string, fields: Record<string, unknown>) => void;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* 真执行器:按核心给的配方跑安装(测试一律注入假零件,不走这里)。
|
|
205
|
+
*
|
|
206
|
+
* 路由只有三条,都由核心的 installRecipe 决定,本文件不按操作系统分支:
|
|
207
|
+
* - desktop-service:桌面宿主公开的 desktopPnpm.runPlugin(参数数组, 使用范围目录);
|
|
208
|
+
* - desktop-manager:官方桌面版进程内的 pluginManager.installBundle(精确版本规格, 选项);
|
|
209
|
+
* - cli-process:subprocess.spawn({ argv: [运行时, …运行时参数, CLI 入口, 'plugin', '--profile', 名, …参数] })。
|
|
210
|
+
* 起进程只经宿主注入的子进程能力,不经 shell、不用 PATH 上的 `dsh` 命令名。
|
|
211
|
+
*/
|
|
212
|
+
export declare function createUpdateExecutor(parts?: ExecutorParts): (args?: {
|
|
213
|
+
version?: string;
|
|
214
|
+
profileName?: string | null;
|
|
215
|
+
environmentKind?: EnvironmentKind;
|
|
216
|
+
}) => Promise<void>;
|
|
217
|
+
export declare const __executorInternals: {
|
|
218
|
+
INSTALL_STDIO: {
|
|
219
|
+
stdin: string;
|
|
220
|
+
stdout: {
|
|
221
|
+
maxBytes: number;
|
|
222
|
+
};
|
|
223
|
+
stderr: {
|
|
224
|
+
maxBytes: number;
|
|
225
|
+
};
|
|
226
|
+
};
|
|
227
|
+
TERMINATION_GRACE_MS: number;
|
|
228
|
+
CLI_PACKAGE_NAME: string;
|
|
229
|
+
};
|
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)
|
|
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
|
-
|
|
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.
|
|
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
|
-
".":
|
|
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": {
|