dsh-session-manager 0.7.1 → 0.7.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/lib/index.js CHANGED
@@ -22,13 +22,14 @@
22
22
  * The compatibility adapter normalizes service access and list snapshots.
23
23
  * Optional live-writer/coordinator internals are guarded before mutation.
24
24
  */
25
+ import { existsSync, readFileSync, writeFileSync, rmSync, renameSync } from "node:fs";
25
26
  import { lstat, mkdir, readdir, realpath, rename, rm } from "node:fs/promises";
26
27
  import { basename, dirname, join } from "node:path";
27
28
  import { randomBytes } from "node:crypto";
28
29
  import { createAnnotationStore } from "./annotation-store.js";
29
30
  import { createWorkspaceArchiveStore, assertWorkspaceId } from "./workspace-archive-store.js";
30
- import { createDshAdapter, normalizeSessionHeader, compareSemver } from "./compat/dsh-adapter.js";
31
- import { ARTIFACT_NAMES, assertSessionId, assertSessionDirectory, encodeSessionSegment, parseSessionGenerationFromFilename, readSessionHeader, readSessionFile, writeTempFile, replaceSessionFile } from "./session-files.js";
31
+ import { createDshAdapter, normalizeSessionHeader, compareSemver, detectPluginSource, isHmrActive, resolveProfileDir } from "./compat/dsh-adapter.js";
32
+ import { ARTIFACT_NAMES, assertSessionId, assertSessionDirectory, candidateSessionDirs, encodeFsSessionSegment, decodeFsSessionSegment, encodeProjectSegment, encodeSessionSegment, parseSessionGenerationFromFilename, readSessionHeader, readSessionFile, writeTempFile, replaceSessionFile } from "./session-files.js";
32
33
 
33
34
  export const name = "dsh-session-manager";
34
35
 
@@ -125,10 +126,16 @@ const scanSessionHeaders = async (persistence, dshHome) => {
125
126
  }
126
127
  const result = () => ({ headers, complete: listed && errors.length === 0, errors });
127
128
  // Skip known files before fopen; retain the orphan/raw-id compatibility path.
129
+ // Only add canonical session directories that do not start and end with "--"
130
+ // to avoid colliding with legacy projectKey candidates (e.g. "--s1--").
128
131
  const knownDirs = new Set();
129
132
  for (const id of seen) {
130
- knownDirs.add(id);
131
- knownDirs.add(encodeSessionSegment(id));
133
+ try {
134
+ const canonical = encodeFsSessionSegment(id);
135
+ if (!canonical.startsWith("--") || !canonical.endsWith("--")) {
136
+ knownDirs.add(canonical);
137
+ }
138
+ } catch (_) {}
132
139
  }
133
140
  if (typeof dshHome !== "function") return result();
134
141
  const root = dshHome("sessions");
@@ -150,7 +157,7 @@ const scanSessionHeaders = async (persistence, dshHome) => {
150
157
  for (const filename of ARTIFACT_NAMES) {
151
158
  try {
152
159
  const header = await readSessionHeader(join(root, proj.name, entry.name, filename));
153
- if (![header.id, encodeSessionSegment(header.id)].includes(entry.name)) {
160
+ if (!candidateSessionDirs(header.id).includes(entry.name)) {
154
161
  throw new Error("会话目录与 header id 不一致");
155
162
  }
156
163
  if (!seen.has(header.id)) { seen.add(header.id); headers.push(header); }
@@ -168,9 +175,199 @@ const scanSessionHeaders = async (persistence, dshHome) => {
168
175
 
169
176
  const listSessionHeaders = async (persistence, dshHome) => (await scanSessionHeaders(persistence, dshHome)).headers;
170
177
 
178
+ const UPDATE_STATUS_FILE = ".dsh-session-manager-update.json";
179
+ const STALE_EXTERNAL_TASK_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes fallback for dead external processes
180
+ const HEARTBEAT_STALE_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes without heartbeat for external processes
181
+
182
+ const TRUSTED_REGISTRIES = new Set([
183
+ "https://registry.npmjs.org",
184
+ "https://registry.npmmirror.com",
185
+ "http://127.0.0.1:4873",
186
+ "http://localhost:4873"
187
+ ]);
188
+
189
+ function normalizeRegistryUrl(url) {
190
+ if (!url || typeof url !== "string") return "";
191
+ const trimmed = url.trim();
192
+ if (!trimmed) return "";
193
+ return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
194
+ }
195
+
196
+ function resolveTrustedRegistry(requested, envRegistry) {
197
+ const normEnv = normalizeRegistryUrl(envRegistry);
198
+ const normReq = normalizeRegistryUrl(requested);
199
+
200
+ if (normReq) {
201
+ if (TRUSTED_REGISTRIES.has(normReq) || (normEnv && normReq === normEnv)) {
202
+ return normReq;
203
+ }
204
+ return null;
205
+ }
206
+
207
+ if (normEnv && (TRUSTED_REGISTRIES.has(normEnv) || normEnv.length > 0)) {
208
+ return normEnv;
209
+ }
210
+
211
+ return "https://registry.npmjs.org";
212
+ }
213
+
214
+ const ACTIVE_UPDATE_TASK_KEY = Symbol.for("dsh-session-manager.activeUpdateTask");
215
+
216
+ function getActiveTask() {
217
+ return globalThis[ACTIVE_UPDATE_TASK_KEY] || null;
218
+ }
219
+
220
+ function setActiveTask(task) {
221
+ if (task) {
222
+ globalThis[ACTIVE_UPDATE_TASK_KEY] = task;
223
+ } else {
224
+ delete globalThis[ACTIVE_UPDATE_TASK_KEY];
225
+ }
226
+ }
227
+
228
+ function releaseActiveTask(taskId) {
229
+ const cur = getActiveTask();
230
+ if (cur && (!taskId || cur.taskId === taskId)) {
231
+ setActiveTask(null);
232
+ }
233
+ }
234
+
235
+ function isPidAlive(pid) {
236
+ if (typeof pid !== "number" || pid <= 0) return false;
237
+ try {
238
+ process.kill(pid, 0);
239
+ return true;
240
+ } catch (err) {
241
+ return err.code === "EPERM";
242
+ }
243
+ }
244
+
245
+ function getUpdateStatusFilePath(profileDir) {
246
+ return profileDir ? join(profileDir, UPDATE_STATUS_FILE) : null;
247
+ }
248
+
249
+ function writeUpdateStatus(profileDir, statusObj) {
250
+ const filePath = getUpdateStatusFilePath(profileDir);
251
+ if (!filePath || !statusObj || typeof statusObj !== "object") return;
252
+ statusObj.updatedAt = Date.now();
253
+ try {
254
+ if (existsSync(filePath)) {
255
+ const existing = JSON.parse(readFileSync(filePath, "utf8"));
256
+ if (existing && existing.taskId && statusObj.taskId && existing.taskId !== statusObj.taskId) {
257
+ if (isDiskTaskActive(existing) && (existing.startedAt || 0) > (statusObj.startedAt || 0)) {
258
+ return;
259
+ }
260
+ }
261
+ }
262
+ } catch (_) {}
263
+ const tmpPath = filePath + ".tmp." + Date.now();
264
+ try {
265
+ writeFileSync(tmpPath, JSON.stringify(statusObj, null, 2), "utf8");
266
+ try {
267
+ renameSync(tmpPath, filePath);
268
+ } catch {
269
+ try { rmSync(filePath, { force: true }); } catch (_) {}
270
+ renameSync(tmpPath, filePath);
271
+ }
272
+ } catch (_) {
273
+ try { if (existsSync(tmpPath)) rmSync(tmpPath, { force: true }); } catch (_) {}
274
+ }
275
+ }
276
+
277
+ function readUpdateStatus(profileDir) {
278
+ const filePath = getUpdateStatusFilePath(profileDir);
279
+ if (!filePath) return null;
280
+ try {
281
+ if (existsSync(filePath)) {
282
+ return JSON.parse(readFileSync(filePath, "utf8"));
283
+ }
284
+ } catch (_) { /* best-effort */ }
285
+ return null;
286
+ }
287
+
288
+ const ACTIVE_TASK_STATUSES = new Set([
289
+ "preparing",
290
+ "removing",
291
+ "removed",
292
+ "installing",
293
+ "verifying",
294
+ "rolling-back"
295
+ ]);
296
+
297
+ function isDiskTaskActive(statusObj) {
298
+ if (!statusObj || typeof statusObj !== "object") return false;
299
+ if (!ACTIVE_TASK_STATUSES.has(statusObj.status)) return false;
300
+
301
+ // If status was recorded by the current process, consult in-memory task
302
+ if (statusObj.pid === process.pid) {
303
+ const inMem = getActiveTask();
304
+ if (!inMem || inMem.taskId !== statusObj.taskId) {
305
+ return false;
306
+ }
307
+ return true;
308
+ }
309
+
310
+ // If status was recorded by another process, check if PID is alive
311
+ if (typeof statusObj.pid === "number") {
312
+ if (!isPidAlive(statusObj.pid)) {
313
+ return false;
314
+ }
315
+ }
316
+
317
+ const lastActive = typeof statusObj.updatedAt === "number"
318
+ ? statusObj.updatedAt
319
+ : (typeof statusObj.startedAt === "number" ? statusObj.startedAt : 0);
320
+ if (Date.now() - lastActive > HEARTBEAT_STALE_TIMEOUT_MS) return false;
321
+ return true;
322
+ }
323
+
324
+ function isTaskActive(statusObj) {
325
+ return isDiskTaskActive(statusObj);
326
+ }
327
+
328
+ function verifyInstalledPackage(profileDir, expectedVersion) {
329
+ if (!profileDir) {
330
+ return { ok: false, error: "无法定位插件运行 profile 目录,无法进行安装校验" };
331
+ }
332
+ const pkgDir = join(profileDir, "node_modules", "dsh-session-manager");
333
+ const pkgFile = join(pkgDir, "package.json");
334
+ const entryIndex = join(pkgDir, "lib", "index.js");
335
+ const entryClient = join(pkgDir, "lib", "client.js");
336
+
337
+ if (!existsSync(pkgFile)) {
338
+ return { ok: false, error: "未找到安装目录或 package.json: " + pkgFile };
339
+ }
340
+ let pkg;
341
+ try {
342
+ pkg = JSON.parse(readFileSync(pkgFile, "utf8"));
343
+ } catch (err) {
344
+ return { ok: false, error: "package.json 解析失败: " + safeErrorMessage(err) };
345
+ }
346
+ if (!pkg || typeof pkg !== "object") {
347
+ return { ok: false, error: "package.json 内容无效" };
348
+ }
349
+ if (pkg.name !== "dsh-session-manager") {
350
+ return { ok: false, error: "安装包名称不符: 预期 dsh-session-manager,实际 " + pkg.name };
351
+ }
352
+ if (expectedVersion && expectedVersion !== "latest" && pkg.version !== expectedVersion) {
353
+ return {
354
+ ok: false,
355
+ version: pkg.version,
356
+ error: "安装版本不匹配: 预期 " + expectedVersion + ",实际安装 " + pkg.version
357
+ };
358
+ }
359
+ if (!existsSync(entryIndex)) {
360
+ return { ok: false, version: pkg.version, error: "缺少服务端入口文件: lib/index.js" };
361
+ }
362
+ if (!existsSync(entryClient)) {
363
+ return { ok: false, version: pkg.version, error: "缺少客户端入口文件: lib/client.js" };
364
+ }
365
+ return { ok: true, version: pkg.version };
366
+ }
367
+
171
368
  export function apply(ctx) {
172
369
  try {
173
- const buildId = "dsh-session-manager v0.7.1 (workspace-archive)";
370
+ const buildId = "dsh-session-manager v0.7.3 (workspace-archive)";
174
371
  if (typeof ctx?.logger?.info === "function") ctx.logger.info(buildId);
175
372
  else if (typeof console !== "undefined" && console?.info) console.info(buildId);
176
373
  } catch (_) { /* ignore logging failures */ }
@@ -1252,14 +1449,19 @@ ${rest}`, "utf8");
1252
1449
  catch (error) { if (error.code === "ENOENT") return undefined; throw error; }
1253
1450
  for (const proj of projects) {
1254
1451
  if (!proj.isDirectory() || proj.isSymbolicLink()) continue;
1255
- for (const candidate of [encodeSessionSegment(sessionId), sessionId]) {
1452
+ for (const candidate of candidateSessionDirs(sessionId)) {
1256
1453
  const dir = join(root, proj.name, candidate);
1257
1454
  try { await assertSessionDirectory(root, dir, sessionId); }
1258
1455
  catch (error) { if (error.code === "ENOENT") continue; throw error; }
1259
1456
  for (const filename of ARTIFACT_NAMES) {
1260
1457
  try {
1261
1458
  const artifact = await read(join(dir, filename));
1262
- if (artifact.meta.id !== sessionId) throw new Error("会话工件 header id 与请求不一致");
1459
+ if (artifact.meta.id !== sessionId) {
1460
+ if (candidate === candidateSessionDirs(sessionId)[0]) {
1461
+ throw new Error("会话工件 header id 与请求不一致");
1462
+ }
1463
+ break;
1464
+ }
1263
1465
  return artifact;
1264
1466
  } catch (error) { if (error.code !== "ENOENT") throw error; }
1265
1467
  }
@@ -2013,14 +2215,91 @@ ${rest}`, "utf8");
2013
2215
  return send(res, 200, { ok: true, result });
2014
2216
  }
2015
2217
  if (req.method === "GET" && path === "/update/check") {
2016
- const currentVersion = adapter.pluginVersion() || "0.7.1";
2017
- let latestVersion = currentVersion;
2218
+ const currentVersion = adapter.pluginVersion() || "0.7.3";
2219
+ const runningVersion = currentVersion;
2220
+ let installedVersion = runningVersion;
2221
+ const profileDir = resolveProfileDir(adapter, ctx);
2222
+ if (profileDir) {
2223
+ const diskCheck = verifyInstalledPackage(profileDir);
2224
+ if (diskCheck.ok && diskCheck.version) {
2225
+ installedVersion = diskCheck.version;
2226
+ }
2227
+ }
2228
+ const pm = adapter.pluginManager;
2229
+ let bundles = [];
2230
+ if (typeof pm?.listBundles === "function") {
2231
+ try { bundles = await pm.listBundles(); } catch (_) {}
2232
+ }
2233
+ const source = detectPluginSource(adapter, ctx, bundles);
2234
+ if (source === "linked") {
2235
+ return send(res, 200, {
2236
+ ok: true,
2237
+ result: {
2238
+ runningVersion,
2239
+ currentVersion: runningVersion,
2240
+ installedVersion,
2241
+ latestVersion: runningVersion,
2242
+ hasUpdate: false,
2243
+ source: "linked",
2244
+ message: "开发模式(本地链接),请手动修改或拉取代码"
2245
+ }
2246
+ });
2247
+ }
2248
+ if (source === "github") {
2249
+ return send(res, 200, {
2250
+ ok: true,
2251
+ result: {
2252
+ runningVersion,
2253
+ currentVersion: runningVersion,
2254
+ installedVersion,
2255
+ latestVersion: runningVersion,
2256
+ hasUpdate: false,
2257
+ source: "github",
2258
+ message: "通过 Git/GitHub 安装,请通过 DSH 插件市场或 git 更新"
2259
+ }
2260
+ });
2261
+ }
2262
+ if (source === "installation") {
2263
+ return send(res, 200, {
2264
+ ok: true,
2265
+ result: {
2266
+ runningVersion,
2267
+ currentVersion: runningVersion,
2268
+ installedVersion,
2269
+ latestVersion: runningVersion,
2270
+ hasUpdate: false,
2271
+ source: "installation",
2272
+ message: "系统内置插件,无法独立更新"
2273
+ }
2274
+ });
2275
+ }
2276
+ if (source === "unknown") {
2277
+ return send(res, 200, {
2278
+ ok: true,
2279
+ result: {
2280
+ runningVersion,
2281
+ currentVersion: runningVersion,
2282
+ installedVersion,
2283
+ latestVersion: runningVersion,
2284
+ hasUpdate: false,
2285
+ source: "unknown",
2286
+ message: "无法确认插件安装来源,为保证安全已禁止自动更新"
2287
+ }
2288
+ });
2289
+ }
2290
+ let latestVersion = installedVersion;
2018
2291
  let hasUpdate = false;
2019
2292
  try {
2293
+ const envRegistry = process.env.NPM_CONFIG_REGISTRY?.trim();
2020
2294
  const requestedRegistry = url.searchParams.get("registry")?.trim();
2021
- const registry = (requestedRegistry && /^https?:\/\//i.test(requestedRegistry))
2022
- ? requestedRegistry
2023
- : (process.env.NPM_CONFIG_REGISTRY || "https://registry.npmjs.org");
2295
+ const registry = resolveTrustedRegistry(requestedRegistry, envRegistry);
2296
+ if (!registry) {
2297
+ return send(res, 400, {
2298
+ ok: false,
2299
+ code: "untrusted-registry",
2300
+ error: "不受信任的 npm 安装源: " + requestedRegistry
2301
+ });
2302
+ }
2024
2303
  const base = registry.endsWith("/") ? registry : registry + "/";
2025
2304
  const pkgUrl = new URL("dsh-session-manager/latest", base).toString();
2026
2305
  const controller = new AbortController();
@@ -2039,17 +2318,33 @@ ${rest}`, "utf8");
2039
2318
  } finally {
2040
2319
  clearTimeout(timer);
2041
2320
  }
2042
- hasUpdate = compareSemver(latestVersion, currentVersion) > 0;
2321
+ hasUpdate = compareSemver(latestVersion, installedVersion) > 0;
2322
+ const restartRequired = compareSemver(installedVersion, runningVersion) > 0;
2043
2323
  return send(res, 200, {
2044
2324
  ok: true,
2045
- result: { currentVersion, latestVersion, hasUpdate }
2325
+ result: {
2326
+ runningVersion,
2327
+ currentVersion: runningVersion,
2328
+ installedVersion,
2329
+ latestVersion,
2330
+ hasUpdate,
2331
+ restartRequired,
2332
+ source: "npm"
2333
+ }
2046
2334
  });
2047
2335
  } catch (err) {
2048
2336
  return send(res, 200, {
2049
2337
  ok: false,
2050
2338
  code: "update-check-failed",
2051
2339
  error: "检查更新失败: " + safeErrorMessage(err),
2052
- result: { currentVersion, latestVersion: currentVersion, hasUpdate: false }
2340
+ result: {
2341
+ runningVersion,
2342
+ currentVersion: runningVersion,
2343
+ installedVersion,
2344
+ latestVersion: installedVersion,
2345
+ hasUpdate: false,
2346
+ source: "npm"
2347
+ }
2053
2348
  });
2054
2349
  }
2055
2350
  }
@@ -2058,38 +2353,650 @@ ${rest}`, "utf8");
2058
2353
  if (rawVersion && !/^[a-zA-Z0-9_.-]+$/.test(rawVersion)) {
2059
2354
  return send(res, 400, { ok: false, code: "bad-request", error: "Invalid version format" });
2060
2355
  }
2061
- const targetVersion = rawVersion || "latest";
2062
- const spec = "dsh-session-manager@" + targetVersion;
2063
- const registry = typeof body?.registry === "string" && /^https?:\/\//i.test(body.registry.trim()) ? body.registry.trim() : "";
2356
+
2064
2357
  const pm = adapter.pluginManager;
2065
- if (pm && typeof pm.installBundle === "function") {
2358
+ if (!pm || typeof pm.installBundle !== "function") {
2359
+ return send(res, 501, {
2360
+ ok: false,
2361
+ code: "plugin-manager-unavailable",
2362
+ error: "当前宿主环境未提供 PluginManager 安装服务,请通过 DSH 插件市场或命令行更新"
2363
+ });
2364
+ }
2365
+
2366
+ const profileDir = resolveProfileDir(adapter, ctx);
2367
+
2368
+ // P1-3: Concurrency Mutex check & immediate synchronous acquisition
2369
+ const inMemoryTask = getActiveTask();
2370
+ if (inMemoryTask) {
2371
+ return send(res, 409, {
2372
+ ok: false,
2373
+ code: "update-in-progress",
2374
+ error: "已有正在执行的更新任务 (" + inMemoryTask.taskId + "),请勿重复操作",
2375
+ taskId: inMemoryTask.taskId
2376
+ });
2377
+ }
2378
+ const diskStatus = readUpdateStatus(profileDir);
2379
+ if (isDiskTaskActive(diskStatus)) {
2380
+ return send(res, 409, {
2381
+ ok: false,
2382
+ code: "update-in-progress",
2383
+ error: "已有正在执行的更新任务 (" + diskStatus.taskId + "),请勿重复操作",
2384
+ taskId: diskStatus.taskId
2385
+ });
2386
+ }
2387
+
2388
+ const taskId = (typeof body?.taskId === "string" && body.taskId.trim())
2389
+ ? body.taskId.trim()
2390
+ : ("update-" + Date.now() + "-" + randomBytes(4).toString("hex"));
2391
+
2392
+ const taskStartedAt = Date.now();
2393
+ const taskRecord = {
2394
+ taskId,
2395
+ startedAt: taskStartedAt,
2396
+ profileDir,
2397
+ pid: process.pid,
2398
+ targetVersion: rawVersion || "latest"
2399
+ };
2400
+ setActiveTask(taskRecord);
2401
+
2402
+ const currentVersion = adapter.pluginVersion() || "0.7.3";
2403
+ let installedVersion = currentVersion;
2404
+ if (profileDir) {
2405
+ const pkgFile = join(profileDir, "node_modules", "dsh-session-manager", "package.json");
2406
+ if (existsSync(pkgFile)) {
2407
+ const diskCheck = verifyInstalledPackage(profileDir);
2408
+ if (diskCheck.ok && diskCheck.version) {
2409
+ installedVersion = diskCheck.version;
2410
+ } else {
2411
+ releaseActiveTask(taskId);
2412
+ return send(res, 500, {
2413
+ ok: false,
2414
+ code: "disk-verify-failed",
2415
+ error: "无法确认已安装版本,磁盘插件目录异常: " + (diskCheck?.error || "package.json 校验失败"),
2416
+ taskId
2417
+ });
2418
+ }
2419
+ }
2420
+ }
2421
+
2422
+ const targetVersion = rawVersion || "latest";
2423
+ let resolvedTargetVersion = (targetVersion !== "latest") ? targetVersion : null;
2424
+ const envRegistry = process.env.NPM_CONFIG_REGISTRY?.trim();
2425
+ const requestedRegistry = typeof body?.registry === "string" ? body.registry.trim() : "";
2426
+ const registry = resolveTrustedRegistry(requestedRegistry, envRegistry);
2427
+ if (!registry) {
2428
+ releaseActiveTask(taskId);
2429
+ return send(res, 400, {
2430
+ ok: false,
2431
+ code: "untrusted-registry",
2432
+ error: "不受信任的 npm 安装源: " + requestedRegistry
2433
+ });
2434
+ }
2435
+ const forwardRegistry = (requestedRegistry && requestedRegistry !== "https://registry.npmjs.org")
2436
+ ? registry
2437
+ : (envRegistry ? registry : "");
2438
+ const isHmr = isHmrActive(adapter, ctx);
2439
+ const prevRegistry = process.env.NPM_CONFIG_REGISTRY;
2440
+
2441
+ let heartbeatTimer = setInterval(() => {
2066
2442
  try {
2067
- const prevRegistry = process.env.NPM_CONFIG_REGISTRY;
2068
- if (registry) {
2069
- process.env.NPM_CONFIG_REGISTRY = registry;
2443
+ const curStatus = readUpdateStatus(profileDir);
2444
+ if (curStatus && curStatus.taskId === taskId && ACTIVE_TASK_STATUSES.has(curStatus.status)) {
2445
+ curStatus.updatedAt = Date.now();
2446
+ writeUpdateStatus(profileDir, curStatus);
2447
+ }
2448
+ } catch (_) {}
2449
+ }, 10000);
2450
+ if (typeof heartbeatTimer?.unref === "function") {
2451
+ heartbeatTimer.unref();
2452
+ }
2453
+
2454
+ try {
2455
+ // P1-4: Source check
2456
+ let bundles = [];
2457
+ if (typeof pm?.listBundles === "function") {
2458
+ try { bundles = await pm.listBundles(); } catch (_) {}
2459
+ }
2460
+ const source = detectPluginSource(adapter, ctx, bundles);
2461
+ if (source === "linked" || source === "github" || source === "installation") {
2462
+ releaseActiveTask(taskId);
2463
+ return send(res, 400, {
2464
+ ok: false,
2465
+ code: "source-protected",
2466
+ error: "当前插件为 " + source + " 安装来源,无法通过 npm 覆盖更新"
2467
+ });
2468
+ }
2469
+ if (source === "unknown") {
2470
+ releaseActiveTask(taskId);
2471
+ return send(res, 400, {
2472
+ ok: false,
2473
+ code: "source-unknown",
2474
+ error: "无法确认插件安装来源,已禁止自动更新以防破坏环境"
2475
+ });
2476
+ }
2477
+
2478
+ if (!resolvedTargetVersion) {
2479
+ try {
2480
+ const regUrl = registry || process.env.NPM_CONFIG_REGISTRY || "https://registry.npmjs.org";
2481
+ const base = regUrl.endsWith("/") ? regUrl : regUrl + "/";
2482
+ const pkgUrl = new URL("dsh-session-manager/latest", base).toString();
2483
+ const ctrl = new AbortController();
2484
+ const timer = setTimeout(() => ctrl.abort(), 4000);
2485
+ try {
2486
+ const resp = await fetch(pkgUrl, { signal: ctrl.signal, headers: { accept: "application/json" } });
2487
+ if (resp.ok) {
2488
+ const d = await resp.json();
2489
+ if (d && typeof d.version === "string") {
2490
+ resolvedTargetVersion = d.version;
2491
+ }
2492
+ }
2493
+ } finally {
2494
+ clearTimeout(timer);
2495
+ }
2496
+ } catch (_) { /* fallback to outcome.version after install */ }
2497
+ }
2498
+
2499
+ const baselineVersion = installedVersion || currentVersion;
2500
+
2501
+ if (resolvedTargetVersion && baselineVersion && baselineVersion !== "unknown") {
2502
+ const cmp = compareSemver(resolvedTargetVersion, baselineVersion);
2503
+ if (cmp === 0) {
2504
+ // If target equals baseline:
2505
+ // Check if already installed on disk pending restart or refresh
2506
+ if (compareSemver(installedVersion, currentVersion) > 0) {
2507
+ releaseActiveTask(taskId);
2508
+ const application = isHmr ? "refresh-required" : "restart-required";
2509
+ writeUpdateStatus(profileDir, {
2510
+ taskId,
2511
+ status: "completed",
2512
+ targetVersion: installedVersion,
2513
+ currentVersion,
2514
+ application,
2515
+ alreadyInstalled: true,
2516
+ pid: process.pid,
2517
+ completedAt: Date.now()
2518
+ });
2519
+ return send(res, 200, {
2520
+ ok: true,
2521
+ result: {
2522
+ application,
2523
+ spec: "dsh-session-manager@" + installedVersion,
2524
+ version: installedVersion,
2525
+ taskId,
2526
+ alreadyInstalled: true
2527
+ }
2528
+ });
2529
+ }
2530
+ // Target is equal to running & installed version -> not an upgrade
2531
+ releaseActiveTask(taskId);
2532
+ return send(res, 400, {
2533
+ ok: false,
2534
+ code: "not-upgrade",
2535
+ error: "目标版本 (" + resolvedTargetVersion + ") 不高于当前已安装版本 (" + baselineVersion + ")"
2536
+ });
2537
+ } else if (cmp < 0) {
2538
+ releaseActiveTask(taskId);
2539
+ return send(res, 400, {
2540
+ ok: false,
2541
+ code: "not-upgrade",
2542
+ error: "目标版本 (" + resolvedTargetVersion + ") 低于当前已安装版本 (" + baselineVersion + ")"
2543
+ });
2544
+ }
2545
+ }
2546
+ const spec = "dsh-session-manager@" + (resolvedTargetVersion || targetVersion);
2547
+ const oldSpec = "dsh-session-manager@" + currentVersion;
2548
+
2549
+ writeUpdateStatus(profileDir, {
2550
+ taskId,
2551
+ status: "preparing",
2552
+ targetVersion: resolvedTargetVersion || targetVersion,
2553
+ currentVersion,
2554
+ source: "npm",
2555
+ pid: process.pid,
2556
+ startedAt: taskStartedAt
2557
+ });
2558
+
2559
+ if (forwardRegistry) {
2560
+ process.env.NPM_CONFIG_REGISTRY = forwardRegistry;
2561
+ }
2562
+
2563
+ // Capture locals before any potential scope teardown
2564
+ const localProfileDir = profileDir;
2565
+ const localSpec = spec;
2566
+ const localOldSpec = oldSpec;
2567
+ const localPm = pm;
2568
+ const localTaskId = taskId;
2569
+ const localTargetVersion = targetVersion;
2570
+ const localCurrentVersion = currentVersion;
2571
+ const localRegistry = forwardRegistry;
2572
+ let localResolvedTargetVersion = resolvedTargetVersion;
2573
+
2574
+ // Helper: perform rollback and record state
2575
+ const performRollback = async (triggerError, { newVersionInstalled = false } = {}) => {
2576
+ // Restore registry environment first so rollback is not blocked by custom registry failure
2577
+ if (forwardRegistry) {
2578
+ if (prevRegistry !== undefined) {
2579
+ process.env.NPM_CONFIG_REGISTRY = prevRegistry;
2580
+ } else {
2581
+ delete process.env.NPM_CONFIG_REGISTRY;
2582
+ }
2583
+ }
2584
+
2585
+ writeUpdateStatus(localProfileDir, {
2586
+ taskId: localTaskId,
2587
+ status: "rolling-back",
2588
+ targetVersion: localResolvedTargetVersion || localTargetVersion,
2589
+ currentVersion: localCurrentVersion,
2590
+ error: triggerError,
2591
+ pid: process.pid,
2592
+ rollingBackAt: Date.now()
2593
+ });
2594
+
2595
+ // P0-2: In HMR mode, if new version was already installed into profile,
2596
+ // calling installBundle(oldSpec) directly throws "already-installed".
2597
+ // Therefore, we must remove the new version first.
2598
+ let cleanError = null;
2599
+ if (isHmr && newVersionInstalled) {
2600
+ if (typeof localPm.removeBundle === "function") {
2601
+ try {
2602
+ const rmOutcome = await localPm.removeBundle("dsh-session-manager");
2603
+ if (rmOutcome && (rmOutcome.application === "failed" || rmOutcome.application === "cancelled" || rmOutcome.ok === false)) {
2604
+ cleanError = rmOutcome.error?.message || rmOutcome.error?.code || safeErrorMessage(rmOutcome.error) || ("application: " + rmOutcome.application);
2605
+ }
2606
+ } catch (rmErr) {
2607
+ cleanError = safeErrorMessage(rmErr);
2608
+ }
2609
+ }
2070
2610
  }
2071
- let outcome;
2611
+
2612
+ if (cleanError) {
2613
+ const rbDetail = "清理安装失败的新版本失败: " + cleanError;
2614
+ releaseActiveTask(localTaskId);
2615
+ const actualCheck = verifyInstalledPackage(localProfileDir);
2616
+ const installedState = actualCheck.ok ? (actualCheck.version || "unknown") : "unverified";
2617
+ writeUpdateStatus(localProfileDir, {
2618
+ taskId: localTaskId,
2619
+ status: "recovery-required",
2620
+ stage: "cleanup-new-version",
2621
+ targetVersion: localResolvedTargetVersion || localTargetVersion,
2622
+ currentVersion: localCurrentVersion,
2623
+ rolledBack: false,
2624
+ error: triggerError,
2625
+ rollbackError: rbDetail,
2626
+ installedState,
2627
+ pid: process.pid,
2628
+ failedAt: Date.now()
2629
+ });
2630
+ if (!res.writableEnded) {
2631
+ return send(res, 500, {
2632
+ ok: false,
2633
+ code: "recovery-required",
2634
+ stage: "cleanup-new-version",
2635
+ error: "安装更新失败: " + triggerError + "(自动恢复旧版本失败: " + rbDetail + ",请手动重新安装插件)",
2636
+ rolledBack: false,
2637
+ rollbackError: rbDetail,
2638
+ originalVersion: localCurrentVersion,
2639
+ targetVersion: localResolvedTargetVersion || localTargetVersion,
2640
+ installedState,
2641
+ taskId: localTaskId
2642
+ });
2643
+ }
2644
+ return;
2645
+ }
2646
+
2647
+ let rbOutcome = null;
2648
+ let rbError = null;
2072
2649
  try {
2073
- outcome = await (registry ? pm.installBundle(spec, { registry }) : pm.installBundle(spec));
2074
- } finally {
2075
- if (registry) {
2076
- if (prevRegistry !== undefined) {
2077
- process.env.NPM_CONFIG_REGISTRY = prevRegistry;
2078
- } else {
2079
- delete process.env.NPM_CONFIG_REGISTRY;
2650
+ rbOutcome = await (localRegistry ? localPm.installBundle(localOldSpec, { registry: localRegistry }) : localPm.installBundle(localOldSpec));
2651
+ } catch (err) {
2652
+ rbError = safeErrorMessage(err);
2653
+ // Fallback to default installBundle without registry option if custom registry fails
2654
+ if (localRegistry) {
2655
+ try {
2656
+ rbOutcome = await localPm.installBundle(localOldSpec);
2657
+ rbError = null;
2658
+ } catch (err2) {
2659
+ rbError = safeErrorMessage(err2);
2080
2660
  }
2081
2661
  }
2082
2662
  }
2083
- const application = outcome?.application || "restart-required";
2084
- return send(res, 200, { ok: true, result: { application, spec } });
2663
+ let installOk = rbOutcome && rbOutcome.application !== "failed" && rbOutcome.application !== "cancelled" && rbOutcome.ok !== false && !rbError;
2664
+
2665
+ if (installOk && typeof localPm.selectBundle === "function") {
2666
+ try { await localPm.selectBundle("dsh-session-manager", true); } catch (_) {}
2667
+ }
2668
+
2669
+ let verifiedOk = false;
2670
+ let verifyError = null;
2671
+ if (installOk) {
2672
+ const v = verifyInstalledPackage(localProfileDir, localCurrentVersion);
2673
+ verifiedOk = v.ok;
2674
+ if (!v.ok) verifyError = v.error;
2675
+ }
2676
+ releaseActiveTask(localTaskId);
2677
+ if (installOk && verifiedOk) {
2678
+ writeUpdateStatus(localProfileDir, {
2679
+ taskId: localTaskId,
2680
+ status: "rolled-back",
2681
+ targetVersion: localResolvedTargetVersion || localTargetVersion,
2682
+ currentVersion: localCurrentVersion,
2683
+ rolledBack: true,
2684
+ error: triggerError,
2685
+ pid: process.pid,
2686
+ failedAt: Date.now()
2687
+ });
2688
+ if (!res.writableEnded) {
2689
+ return send(res, 500, {
2690
+ ok: false,
2691
+ code: "install-failed",
2692
+ error: "安装更新失败: " + triggerError + "(已自动恢复旧版本 v" + localCurrentVersion + ")",
2693
+ rolledBack: true,
2694
+ originalVersion: localCurrentVersion,
2695
+ targetVersion: localResolvedTargetVersion || localTargetVersion,
2696
+ taskId: localTaskId
2697
+ });
2698
+ }
2699
+ } else {
2700
+ const failureStage = !installOk ? "reinstall-old-version" : "verify-rollback";
2701
+ const rbDetail = rbError || verifyError || (rbOutcome?.error ? safeErrorMessage(rbOutcome.error) : "回滚安装未成功");
2702
+ const actualCheck = verifyInstalledPackage(localProfileDir);
2703
+ const installedState = actualCheck.ok ? (actualCheck.version || "unknown") : "unverified";
2704
+ writeUpdateStatus(localProfileDir, {
2705
+ taskId: localTaskId,
2706
+ status: "recovery-required",
2707
+ stage: failureStage,
2708
+ targetVersion: localResolvedTargetVersion || localTargetVersion,
2709
+ currentVersion: localCurrentVersion,
2710
+ rolledBack: false,
2711
+ error: triggerError,
2712
+ rollbackError: rbDetail,
2713
+ installedState,
2714
+ pid: process.pid,
2715
+ failedAt: Date.now()
2716
+ });
2717
+ if (!res.writableEnded) {
2718
+ return send(res, 500, {
2719
+ ok: false,
2720
+ code: "recovery-required",
2721
+ stage: failureStage,
2722
+ error: "安装更新失败: " + triggerError + "(自动恢复旧版本失败: " + rbDetail + ",请手动重新安装插件)",
2723
+ rolledBack: false,
2724
+ rollbackError: rbDetail,
2725
+ originalVersion: localCurrentVersion,
2726
+ targetVersion: localResolvedTargetVersion || localTargetVersion,
2727
+ installedState,
2728
+ taskId: localTaskId
2729
+ });
2730
+ }
2731
+ }
2732
+ };
2733
+
2734
+ if (isHmr && typeof localPm.removeBundle === "function") {
2735
+ // P0-1 & P0-2: Remove old bundle first in HMR
2736
+ writeUpdateStatus(localProfileDir, {
2737
+ taskId: localTaskId,
2738
+ status: "removing",
2739
+ targetVersion: localTargetVersion,
2740
+ currentVersion: localCurrentVersion,
2741
+ source: "npm",
2742
+ pid: process.pid,
2743
+ startedAt: taskStartedAt
2744
+ });
2745
+
2746
+ let removeResult;
2747
+ try {
2748
+ removeResult = await localPm.removeBundle("dsh-session-manager");
2749
+ } catch (rmErr) {
2750
+ const failReason = safeErrorMessage(rmErr);
2751
+ releaseActiveTask(localTaskId);
2752
+ writeUpdateStatus(localProfileDir, {
2753
+ taskId: localTaskId,
2754
+ status: "failed",
2755
+ stage: "removing",
2756
+ targetVersion: localTargetVersion,
2757
+ currentVersion: localCurrentVersion,
2758
+ error: "卸载旧版本失败: " + failReason,
2759
+ pid: process.pid,
2760
+ failedAt: Date.now()
2761
+ });
2762
+ return send(res, 500, {
2763
+ ok: false,
2764
+ code: "remove-failed",
2765
+ error: "卸载旧版本失败: " + failReason,
2766
+ taskId: localTaskId
2767
+ });
2768
+ }
2769
+
2770
+ // P0-2: Strictly check removeBundle return value
2771
+ if (removeResult && (removeResult.application === "failed" || removeResult.application === "cancelled" || removeResult.ok === false)) {
2772
+ const failReason = removeResult.error?.message || removeResult.error?.code || safeErrorMessage(removeResult.error) || ("application: " + removeResult.application);
2773
+ if (removeResult.changed && typeof localPm.selectBundle === "function") {
2774
+ try { await localPm.selectBundle("dsh-session-manager", true); } catch (_) {}
2775
+ }
2776
+ releaseActiveTask(localTaskId);
2777
+ writeUpdateStatus(localProfileDir, {
2778
+ taskId: localTaskId,
2779
+ status: "failed",
2780
+ stage: "removing",
2781
+ targetVersion: localTargetVersion,
2782
+ currentVersion: localCurrentVersion,
2783
+ error: "卸载旧版本失败: " + failReason,
2784
+ pid: process.pid,
2785
+ failedAt: Date.now()
2786
+ });
2787
+ return send(res, 500, {
2788
+ ok: false,
2789
+ code: "remove-failed",
2790
+ error: "卸载旧版本失败: " + failReason,
2791
+ taskId: localTaskId
2792
+ });
2793
+ }
2794
+
2795
+ writeUpdateStatus(localProfileDir, {
2796
+ taskId: localTaskId,
2797
+ status: "removed",
2798
+ targetVersion: localTargetVersion,
2799
+ currentVersion: localCurrentVersion,
2800
+ source: "npm",
2801
+ pid: process.pid,
2802
+ startedAt: taskStartedAt
2803
+ });
2804
+ }
2805
+
2806
+ // Install target bundle
2807
+ writeUpdateStatus(localProfileDir, {
2808
+ taskId: localTaskId,
2809
+ status: "installing",
2810
+ targetVersion: localResolvedTargetVersion || localTargetVersion,
2811
+ currentVersion: localCurrentVersion,
2812
+ source: "npm",
2813
+ pid: process.pid,
2814
+ startedAt: taskStartedAt
2815
+ });
2816
+
2817
+ let outcome;
2818
+ try {
2819
+ outcome = await (localRegistry ? localPm.installBundle(localSpec, { registry: localRegistry }) : localPm.installBundle(localSpec));
2085
2820
  } catch (err) {
2086
- return send(res, 500, { ok: false, code: "install-failed", error: safeErrorMessage(err) });
2821
+ const errMsg = safeErrorMessage(err);
2822
+ if (isHmr) {
2823
+ // In HMR, old bundle was removed -> execute rollback (new version was not installed)
2824
+ return await performRollback(errMsg, { newVersionInstalled: false });
2825
+ } else {
2826
+ // In non-HMR, old bundle was not removed -> fail directly
2827
+ releaseActiveTask(localTaskId);
2828
+ writeUpdateStatus(localProfileDir, {
2829
+ taskId: localTaskId,
2830
+ status: "failed",
2831
+ targetVersion: localResolvedTargetVersion || localTargetVersion,
2832
+ currentVersion: localCurrentVersion,
2833
+ error: errMsg,
2834
+ pid: process.pid,
2835
+ failedAt: Date.now()
2836
+ });
2837
+ return send(res, 500, {
2838
+ ok: false,
2839
+ code: "install-failed",
2840
+ error: "安装更新失败: " + errMsg,
2841
+ taskId: localTaskId
2842
+ });
2843
+ }
2844
+ }
2845
+
2846
+ // Check outcome application
2847
+ if (outcome && (outcome.application === "failed" || outcome.application === "cancelled" || outcome.ok === false)) {
2848
+ const failReason = outcome.error?.message || outcome.error || ("application: " + outcome.application);
2849
+ if (isHmr) {
2850
+ return await performRollback(failReason, { newVersionInstalled: false });
2851
+ } else {
2852
+ releaseActiveTask(localTaskId);
2853
+ writeUpdateStatus(localProfileDir, {
2854
+ taskId: localTaskId,
2855
+ status: "failed",
2856
+ targetVersion: localResolvedTargetVersion || localTargetVersion,
2857
+ currentVersion: localCurrentVersion,
2858
+ error: failReason,
2859
+ pid: process.pid,
2860
+ failedAt: Date.now()
2861
+ });
2862
+ return send(res, 500, {
2863
+ ok: false,
2864
+ code: "install-failed",
2865
+ error: "安装更新失败: " + failReason,
2866
+ taskId: localTaskId
2867
+ });
2868
+ }
2087
2869
  }
2870
+
2871
+ if (!localResolvedTargetVersion && typeof outcome?.version === "string") {
2872
+ localResolvedTargetVersion = outcome.version;
2873
+ }
2874
+ const concreteTargetVersion = localResolvedTargetVersion || (localTargetVersion !== "latest" ? localTargetVersion : null);
2875
+
2876
+ // P1-2: Strict post-install verification
2877
+ writeUpdateStatus(localProfileDir, {
2878
+ taskId: localTaskId,
2879
+ status: "verifying",
2880
+ targetVersion: concreteTargetVersion || localTargetVersion,
2881
+ currentVersion: localCurrentVersion,
2882
+ source: "npm",
2883
+ pid: process.pid,
2884
+ startedAt: taskStartedAt
2885
+ });
2886
+
2887
+ const verifyResult = verifyInstalledPackage(localProfileDir, concreteTargetVersion);
2888
+ if (!verifyResult.ok) {
2889
+ const vError = "安装后校验失败: " + verifyResult.error;
2890
+ return await performRollback(vError, { newVersionInstalled: true });
2891
+ }
2892
+
2893
+ // Completed!
2894
+ const application = outcome?.application || (isHmr ? "refresh-required" : "restart-required");
2895
+ releaseActiveTask(localTaskId);
2896
+ const finalInstalledVersion = verifyResult.version || concreteTargetVersion || localTargetVersion;
2897
+ writeUpdateStatus(localProfileDir, {
2898
+ taskId: localTaskId,
2899
+ status: "completed",
2900
+ targetVersion: finalInstalledVersion,
2901
+ currentVersion: localCurrentVersion,
2902
+ application,
2903
+ pid: process.pid,
2904
+ completedAt: Date.now()
2905
+ });
2906
+
2907
+ if (!res.writableEnded) {
2908
+ return send(res, 200, {
2909
+ ok: true,
2910
+ result: {
2911
+ application,
2912
+ spec: localSpec,
2913
+ version: finalInstalledVersion,
2914
+ taskId: localTaskId
2915
+ }
2916
+ });
2917
+ }
2918
+ } finally {
2919
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
2920
+ releaseActiveTask(taskId);
2921
+ if (forwardRegistry) {
2922
+ if (prevRegistry !== undefined) {
2923
+ process.env.NPM_CONFIG_REGISTRY = prevRegistry;
2924
+ } else {
2925
+ delete process.env.NPM_CONFIG_REGISTRY;
2926
+ }
2927
+ }
2928
+ }
2929
+ }
2930
+ if (req.method === "GET" && path === "/update/status") {
2931
+ const profileDir = resolveProfileDir(adapter, ctx);
2932
+ const currentVersion = adapter.pluginVersion() || "0.7.3";
2933
+ let installedVersion = currentVersion;
2934
+ if (profileDir) {
2935
+ const diskCheck = verifyInstalledPackage(profileDir);
2936
+ if (diskCheck.ok && diskCheck.version) {
2937
+ installedVersion = diskCheck.version;
2938
+ }
2939
+ }
2940
+ const updateStatus = readUpdateStatus(profileDir);
2941
+ const queryTaskId = url.searchParams.get("taskId")?.trim();
2942
+
2943
+ if (queryTaskId) {
2944
+ if (updateStatus && updateStatus.taskId === queryTaskId) {
2945
+ return send(res, 200, {
2946
+ ok: true,
2947
+ result: {
2948
+ ...updateStatus,
2949
+ runningVersion: currentVersion,
2950
+ currentVersion,
2951
+ installedVersion
2952
+ }
2953
+ });
2954
+ }
2955
+ if (updateStatus && updateStatus.taskId && updateStatus.taskId !== queryTaskId) {
2956
+ return send(res, 200, {
2957
+ ok: true,
2958
+ result: {
2959
+ status: "superseded",
2960
+ taskId: queryTaskId,
2961
+ activeTaskId: updateStatus.taskId,
2962
+ runningVersion: currentVersion,
2963
+ currentVersion,
2964
+ installedVersion
2965
+ }
2966
+ });
2967
+ }
2968
+ return send(res, 200, {
2969
+ ok: true,
2970
+ result: {
2971
+ status: "idle",
2972
+ taskId: queryTaskId,
2973
+ runningVersion: currentVersion,
2974
+ currentVersion,
2975
+ installedVersion
2976
+ }
2977
+ });
2088
2978
  }
2089
- return send(res, 501, {
2090
- ok: false,
2091
- code: "plugin-manager-unavailable",
2092
- error: "当前宿主环境未提供 PluginManager 安装服务,请通过 DSH 插件市场或命令行更新"
2979
+
2980
+ const now = Date.now();
2981
+ if (updateStatus && (now - (updateStatus.completedAt || updateStatus.failedAt || updateStatus.startedAt || 0) < 10 * 60 * 1000)) {
2982
+ return send(res, 200, {
2983
+ ok: true,
2984
+ result: {
2985
+ ...updateStatus,
2986
+ runningVersion: currentVersion,
2987
+ currentVersion,
2988
+ installedVersion
2989
+ }
2990
+ });
2991
+ }
2992
+ return send(res, 200, {
2993
+ ok: true,
2994
+ result: {
2995
+ status: "idle",
2996
+ runningVersion: currentVersion,
2997
+ currentVersion,
2998
+ installedVersion
2999
+ }
2093
3000
  });
2094
3001
  }
2095
3002
  return send(res, 404, { ok: false, error: "not found: " + req.method + " " + path });
@@ -2111,4 +3018,4 @@ ${rest}`, "utf8");
2111
3018
  }), "session-manager: http api");
2112
3019
  }
2113
3020
 
2114
- export { listSessionHeaders, scanSessionHeaders, encodeSessionSegment, compareSemver };
3021
+ export { listSessionHeaders, scanSessionHeaders, candidateSessionDirs, encodeFsSessionSegment, decodeFsSessionSegment, encodeProjectSegment, encodeSessionSegment, compareSemver };