dsh-hot-reload 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,45 @@
1
+ # Changelog
2
+
3
+ All notable changes to `dsh-hot-reload` are documented here. This project
4
+ follows [semantic versioning](https://semver.org/).
5
+
6
+ ## 0.1.3
7
+
8
+ Code-review fixes (engine + CI):
9
+
10
+ - Reload each changed package **once per module**, not once per plugin row —
11
+ rows sharing a specifier share a runtime, so per-row reloading double-applied
12
+ and leaked a module instance.
13
+ - **Serialize reload cycles** so a change arriving mid-reload can't run a second
14
+ cycle concurrently against the registry.
15
+ - Commit the tracked version **only after a successful reload/skip**, so a failed
16
+ reload can be retried by re-installing the same version.
17
+ - **Validate the profile dir** (warn if no `pnpm-lock.yaml`), and prefer a
18
+ candidate dir that actually contains the lockfile.
19
+ - Disposer now awaits `watcher.close()` and guards against in-flight reloads via
20
+ a `disposed` flag.
21
+ - CI: add a `concurrency` group so a commit+tag push can't race to publish
22
+ (E403); publish only on a confirmed `E404` (not on transient `npm view`
23
+ failures).
24
+
25
+ ## 0.1.2
26
+
27
+ - Docs: add a **Compatibility** section (built/tested against dsh `0.1.0-rc.6`;
28
+ relies on cordis/loader internals, fails safe if they're absent).
29
+ - Docs: note that the plugin works in **any profile**, not just `web`.
30
+ - CI: publish workflow now also triggers on `v*` **tags** (in addition to pushes
31
+ to `main`), so releases can be cut either way.
32
+
33
+ ## 0.1.1
34
+
35
+ - Add Chinese README (`README.zh.md`) and ship it in the published package.
36
+
37
+ ## 0.1.0
38
+
39
+ - Initial release. Watches the profile's `pnpm-lock.yaml` and live-reloads an
40
+ upgraded plugin's module in place (invalidate + re-import + fiber swap,
41
+ mirroring `cordis-plugin-hmr` into `node_modules`).
42
+ - Optimistic with rollback: a failed reload (load error, or a sync/async `apply`
43
+ throw) keeps the old version live and logs that a manual restart is needed.
44
+ - `dsh.hotReload: false` opt-out; degrades to "restart needed" when loader
45
+ internals are unavailable.
package/README.md CHANGED
@@ -41,6 +41,18 @@ apply live:
41
41
  dsh plugin --profile web add some-plugin@newer # reloaded automatically
42
42
  ```
43
43
 
44
+ Works in **any profile** — swap `web` for whichever profile you use; it watches
45
+ the profile it's loaded into.
46
+
47
+ ## Compatibility
48
+
49
+ Built and tested against **dsh `0.1.0-rc.6`** (Node 22 / 24). It reaches into
50
+ cordis/loader internals shared with `cordis-plugin-hmr`
51
+ (`loader.internal.loadCache`, `registry.plugin`/`delete`, `fiber.entry`), so a
52
+ future dsh that changes those may require an update. It fails safe: if the
53
+ internals it needs are missing, it degrades to reporting "restart needed" rather
54
+ than breaking dsh.
55
+
44
56
  ## Opting out
45
57
 
46
58
  A plugin that knows it isn't safe to hot-reload can force the restart-needed
package/README.zh.md CHANGED
@@ -36,6 +36,17 @@ dsh plugin --profile web add dsh-hot-reload
36
36
  dsh plugin --profile web add some-plugin@newer # 自动热重载
37
37
  ```
38
38
 
39
+ 适用于**任意 profile**——把 `web` 换成你用的 profile 即可;它监听自己被加载进的
40
+ 那个 profile。
41
+
42
+ ## 兼容性
43
+
44
+ 基于并测试于 **dsh `0.1.0-rc.6`**(Node 22 / 24)。它会用到与 `cordis-plugin-hmr`
45
+ 共享的 cordis/loader 内部(`loader.internal.loadCache`、`registry.plugin`/
46
+ `delete`、`fiber.entry`),因此未来若某个 dsh 版本改动了这些内部,可能需要更新
47
+ 本插件。它是失败安全的:一旦所需内部不可用,会退化为报告“需要重启”,而不会
48
+ 弄坏 dsh。
49
+
39
50
  ## 退出热重载(opt-out)
40
51
 
41
52
  某个插件若知道自己不适合热重载,可在其**自己的** `package.json` 里声明,强制走
package/lib/index.js CHANGED
@@ -20,7 +20,7 @@
20
20
  // crash dsh.
21
21
 
22
22
  import { watch } from "chokidar";
23
- import { readFileSync } from "node:fs";
23
+ import { readFileSync, existsSync } from "node:fs";
24
24
  import { createRequire } from "node:module";
25
25
  import { fileURLToPath } from "node:url";
26
26
  import { dirname, join } from "node:path";
@@ -55,6 +55,14 @@ export function apply(ctx, config = {}) {
55
55
  }
56
56
  const lockfile = join(profileDir, "pnpm-lock.yaml");
57
57
  const nodeModules = join(profileDir, "node_modules");
58
+ // Validate the auto-detected dir loudly: watching a wrong/nonexistent lockfile
59
+ // would silently track 0 packages and never fire.
60
+ if (!existsSync(lockfile)) {
61
+ log.warn?.(
62
+ `dsh-hot-reload: no pnpm-lock.yaml at ${lockfile} — is this the profile dir? ` +
63
+ "set config.profileDir to fix; the plugin will watch but detect nothing until it appears."
64
+ );
65
+ }
58
66
 
59
67
  // ---- package <-> loader-entry helpers ----
60
68
 
@@ -196,26 +204,44 @@ export function apply(ctx, config = {}) {
196
204
 
197
205
  // ---- change handling ----
198
206
 
207
+ /** Reload every plugin row of a changed package. Returns true if the new
208
+ * version should be committed to the tracked snapshot (success, or a
209
+ * terminal skip); false only when a reload was attempted and FAILED, so an
210
+ * identical re-install can retry. */
199
211
  async function handlePackage(pkg) {
200
212
  const affected = entriesForPkg(pkg);
201
- if (!affected.length) return; // not a loaded plugin (e.g. a fresh install) — out of scope
213
+ if (!affected.length) return true; // not a loaded plugin (fresh install) — out of scope
202
214
  const version = versionOf(pkg) ?? "?";
203
215
 
204
216
  if (optedOut(pkg)) {
205
217
  log.info?.(`dsh-hot-reload: ${pkg}@${version} sets dsh.hotReload:false — restart dsh to load the new version`);
206
- return;
218
+ return true;
207
219
  }
208
220
  if (!internal) {
209
221
  log.info?.(`dsh-hot-reload: ${pkg}@${version} changed — restart dsh to load the new version`);
210
- return;
222
+ return true;
211
223
  }
212
224
 
225
+ // De-duplicate by module specifier: rows sharing one specifier share one
226
+ // runtime, and reloadEntry swaps ALL of that runtime's fibers at once —
227
+ // reloading per-entry would re-import and double-apply. Distinct specifiers
228
+ // (e.g. a package with several host files) each still get reloaded.
229
+ const seen = new Set();
230
+ const modules = affected.filter((e) => {
231
+ const key = e?.options?.name;
232
+ if (!key || seen.has(key)) return false;
233
+ seen.add(key);
234
+ return true;
235
+ });
236
+
213
237
  try {
214
- for (const entry of affected) await reloadEntry(entry);
215
- log.info?.(`dsh-hot-reload: hot-reloaded ${pkg}@${version} (${affected.length} plugin row(s))`);
238
+ for (const entry of modules) await reloadEntry(entry);
239
+ log.info?.(`dsh-hot-reload: hot-reloaded ${pkg}@${version} (${modules.length} module(s))`);
240
+ return true;
216
241
  } catch (err) {
217
242
  log.warn?.(`dsh-hot-reload: could not hot-reload ${pkg}@${version} — restart dsh to load the new version`);
218
243
  log.warn?.(err);
244
+ return false;
219
245
  }
220
246
  }
221
247
 
@@ -223,17 +249,27 @@ export function apply(ctx, config = {}) {
223
249
 
224
250
  let versions = snapshotVersions();
225
251
  let timer = null;
252
+ let disposed = false;
253
+ let running = Promise.resolve(); // serializes reload cycles across debounce batches
254
+
255
+ async function runCycle() {
256
+ if (disposed) return;
257
+ const next = snapshotVersions();
258
+ for (const pkg in next) {
259
+ if (disposed) return;
260
+ if (versions[pkg] === next[pkg]) continue;
261
+ const commit = await handlePackage(pkg);
262
+ if (commit) versions[pkg] = next[pkg]; // commit only on success/skip (failed reload retries)
263
+ }
264
+ for (const pkg of Object.keys(versions)) if (!(pkg in next)) delete versions[pkg]; // drop uninstalled
265
+ }
266
+
226
267
  const trigger = () => {
268
+ if (disposed) return;
227
269
  if (timer) clearTimeout(timer);
228
- timer = setTimeout(async () => {
270
+ timer = setTimeout(() => {
229
271
  timer = null;
230
- const next = snapshotVersions();
231
- const changed = [];
232
- for (const pkg in next) {
233
- if (versions[pkg] !== next[pkg]) changed.push(pkg);
234
- }
235
- versions = next;
236
- for (const pkg of changed) await handlePackage(pkg);
272
+ running = running.then(runCycle).catch((e) => log.warn?.("dsh-hot-reload: reload cycle error", e));
237
273
  }, debounceMs);
238
274
  };
239
275
 
@@ -242,9 +278,13 @@ export function apply(ctx, config = {}) {
242
278
  watcher.on("add", trigger);
243
279
  watcher.on("error", (e) => log.warn?.("dsh-hot-reload: watcher error", e));
244
280
 
245
- ctx.effect(() => () => {
281
+ ctx.effect(() => async () => {
282
+ disposed = true;
246
283
  if (timer) clearTimeout(timer);
247
- watcher.close();
284
+ try {
285
+ await watcher.close();
286
+ } catch {}
287
+ await running.catch(() => {}); // let any in-flight cycle settle
248
288
  });
249
289
 
250
290
  log.info?.(
@@ -252,14 +292,16 @@ export function apply(ctx, config = {}) {
252
292
  );
253
293
  }
254
294
 
255
- /** Best-effort profile-dir resolution: explicit config, then the loader base URL. */
295
+ /** Best-effort profile-dir resolution: explicit config, then the loader base URL.
296
+ * Prefer a candidate that actually contains a pnpm-lock.yaml. */
256
297
  function resolveProfileDir(ctx, config) {
257
- if (config.profileDir) return config.profileDir;
298
+ const candidates = [];
299
+ if (config.profileDir) candidates.push(config.profileDir);
258
300
  try {
259
- if (ctx.baseUrl) {
260
- const p = fileURLToPath(new URL(".", ctx.baseUrl));
261
- return p.replace(/\/$/, "");
262
- }
301
+ if (ctx.baseUrl) candidates.push(fileURLToPath(new URL(".", ctx.baseUrl)).replace(/\/$/, ""));
263
302
  } catch {}
264
- return null;
303
+ for (const dir of candidates) {
304
+ if (existsSync(join(dir, "pnpm-lock.yaml"))) return dir;
305
+ }
306
+ return candidates[0] ?? null; // fall back (apply() warns if the lockfile is missing)
265
307
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "dsh-hot-reload",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
- "description": "Live-reload upgraded DeepSeek Harness (dsh) plugins without restarting dsh — safe plugins are hot-reloaded in place; unsafe ones are flagged for a manual restart.",
5
+ "description": "Live-reload upgraded DeepSeek Harness (dsh) plugins without restarting dsh \u2014 safe plugins are hot-reloaded in place; unsafe ones are flagged for a manual restart.",
6
6
  "keywords": [
7
7
  "dsh",
8
8
  "dsh-plugin",
@@ -36,6 +36,7 @@
36
36
  "cordis.patch.yml",
37
37
  "README.md",
38
38
  "README.zh.md",
39
+ "CHANGELOG.md",
39
40
  "LICENSE"
40
41
  ],
41
42
  "dsh": {