dsh-hot-reload 0.1.2 → 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 CHANGED
@@ -3,6 +3,25 @@
3
3
  All notable changes to `dsh-hot-reload` are documented here. This project
4
4
  follows [semantic versioning](https://semver.org/).
5
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
+
6
25
  ## 0.1.2
7
26
 
8
27
  - Docs: add a **Compatibility** section (built/tested against dsh `0.1.0-rc.6`;
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.2",
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",