herdr-remote 0.2.29 → 0.2.30

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.
@@ -12,6 +12,7 @@ import { cliEntryPoint } from './keepalive/environment.js';
12
12
  import { servicesRunning, startAll, stopAll } from './lifecycle.js';
13
13
  import { checkForRelayUpdate, performRelayUpdate } from './relay-updater.js';
14
14
  import { checkForUpdate, performUpdate } from './updater.js';
15
+ import { heldByAnotherProcess } from './updater/in-place.js';
15
16
  const RESTART_TIMEOUT_MS = 120_000;
16
17
  function restartWithInstalledRelease() {
17
18
  const result = spawnSync(process.execPath, [cliEntryPoint(), 'restart', '--json'], {
@@ -91,7 +92,7 @@ export async function runUpdate(t, options = {}) {
91
92
  report(t(result.errorKey ?? 'update.errorFailed', { version, installed: result.installed ?? '' }));
92
93
  if (result.summary)
93
94
  report(t('update.errorFailedDetail', { message: result.summary }));
94
- if (platform === 'win32' && /\b(EBUSY|EPERM)\b/.test(result.output ?? '')) {
95
+ if (platform === 'win32' && heldByAnotherProcess(result.output)) {
95
96
  report(t('update.errorBusy'));
96
97
  }
97
98
  const { errorKey, summary } = result;
@@ -0,0 +1,83 @@
1
+ // Installing a release when npm cannot replace this one on Windows.
2
+ //
3
+ // npm replaces a global package by renaming its directory, and Windows refuses
4
+ // while any process works in that directory or runs a file from it: a shell or
5
+ // editor left there, a program an older release started. Nobody can find and
6
+ // stop them all. The files inside can still be replaced, so npm installs the
7
+ // release into a staging prefix and it is copied over this one file by file.
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { PACKAGE_ROOT, stateDir } from '../paths.js';
11
+ const HELD_FILE_CODES = ['EBUSY', 'EPERM', 'EACCES'];
12
+ /** npm could not rename the package directory because something holds it. */
13
+ export function heldByAnotherProcess(npmOutput) {
14
+ return /\b(EBUSY|EPERM)\b/.test(npmOutput ?? '');
15
+ }
16
+ function replaceFile(source, target, copyFile) {
17
+ try {
18
+ copyFile(source, target);
19
+ }
20
+ catch (error) {
21
+ if (!HELD_FILE_CODES.includes(error.code ?? ''))
22
+ throw error;
23
+ // A running program or a loaded module cannot be overwritten, but it can be renamed.
24
+ fs.renameSync(target, `${target}.${process.pid}.old`);
25
+ copyFile(source, target);
26
+ }
27
+ }
28
+ /** Make `target` hold what `source` holds, without renaming any directory. */
29
+ export function replaceTree(source, target, copyFile = fs.copyFileSync) {
30
+ fs.mkdirSync(target, { recursive: true });
31
+ // Lowercase because Windows reads names that differ only in case as one file.
32
+ const kept = new Set();
33
+ for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
34
+ kept.add(entry.name.toLowerCase());
35
+ const from = path.join(source, entry.name);
36
+ const to = path.join(target, entry.name);
37
+ const existing = fs.lstatSync(to, { throwIfNoEntry: false });
38
+ if (existing && existing.isDirectory() !== entry.isDirectory())
39
+ fs.rmSync(to, { recursive: true, force: true });
40
+ if (entry.isDirectory())
41
+ replaceTree(from, to, copyFile);
42
+ else
43
+ replaceFile(from, to, copyFile);
44
+ }
45
+ for (const name of fs.readdirSync(target)) {
46
+ if (kept.has(name.toLowerCase()))
47
+ continue;
48
+ try {
49
+ fs.rmSync(path.join(target, name), { recursive: true, force: true });
50
+ }
51
+ catch {
52
+ // Still held, like a file renamed aside above; the next update removes it.
53
+ }
54
+ }
55
+ }
56
+ /** Run the failed `npm install -g` again into a staging prefix, then copy the result over `target`. */
57
+ export async function installInPlace(args, run, { target = PACKAGE_ROOT, stage = path.join(stateDir(), 'update-stage'), platform = process.platform, } = {}) {
58
+ fs.rmSync(stage, { recursive: true, force: true });
59
+ try {
60
+ const result = await run([...args, '--prefix', stage]);
61
+ if (!result.ok)
62
+ return result;
63
+ // npm's global layout under a prefix.
64
+ const modules = platform === 'win32'
65
+ ? path.join(stage, 'node_modules')
66
+ : path.join(stage, 'lib', 'node_modules');
67
+ try {
68
+ replaceTree(path.join(modules, path.basename(target)), target);
69
+ }
70
+ catch (error) {
71
+ return { ok: false, output: `${result.output}\n${error.message}` };
72
+ }
73
+ return result;
74
+ }
75
+ finally {
76
+ try {
77
+ fs.rmSync(stage, { recursive: true, force: true });
78
+ }
79
+ catch {
80
+ // A stage left behind is emptied before the next one is used.
81
+ }
82
+ }
83
+ }
package/dist/updater.js CHANGED
@@ -9,6 +9,7 @@ import os from 'node:os';
9
9
  import path from 'node:path';
10
10
  import { spawn } from 'node:child_process';
11
11
  import { PACKAGE_ROOT } from './paths.js';
12
+ import { heldByAnotherProcess, installInPlace } from './updater/in-place.js';
12
13
  import { runNpm } from './updater/npm.js';
13
14
  const PACKAGE_NAME = 'herdr-remote';
14
15
  const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
@@ -284,6 +285,8 @@ const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
284
285
  * version although its dist-tag already points at it: npm answers ETARGET.
285
286
  * That is a wait, not a failure, so it is retried a few times, then the next
286
287
  * registry that carries the version is tried.
288
+ * - On Windows, when something holds the package directory so npm cannot
289
+ * rename it, the release is copied over it instead.
287
290
  * - Success is what is on disk afterwards, not npm's exit code.
288
291
  */
289
292
  async function performUpdate({ spawnImpl = spawn, timeoutMs = 180_000,
@@ -292,7 +295,7 @@ async function performUpdate({ spawnImpl = spawn, timeoutMs = 180_000,
292
295
  installKindImpl = installKind,
293
296
  // The registries that reported `version`, preferred first. Empty means
294
297
  // "whatever npm is configured with", which is right when no check has run.
295
- registry = '', sources = [], version = null, attempts = 3, retryDelayMs = 15_000, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), readInstalledVersion = installedVersionOnDisk, onAttempt = () => { }, } = {}) {
298
+ registry = '', sources = [], version = null, attempts = 3, retryDelayMs = 15_000, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), readInstalledVersion = installedVersionOnDisk, onAttempt = () => { }, platform = process.platform, inPlace = installInPlace, } = {}) {
296
299
  const kind = installKindImpl();
297
300
  if (kind !== 'npm')
298
301
  return { ok: false, errorKey: `update.cannot.${kind}` };
@@ -314,7 +317,9 @@ registry = '', sources = [], version = null, attempts = 3, retryDelayMs = 15_000
314
317
  const args = ['install', '-g', spec, '--prefer-online'];
315
318
  if (source)
316
319
  args.push('--registry', source);
317
- const result = await runNpm(spawnImpl, args, timeoutMs);
320
+ let result = await runNpm(spawnImpl, args, timeoutMs);
321
+ if (!result.ok && platform === 'win32' && heldByAnotherProcess(result.output))
322
+ result = await inPlace(args, (stagedArgs) => runNpm(spawnImpl, stagedArgs, timeoutMs));
318
323
  if (result.ok) {
319
324
  const installed = readInstalledVersion();
320
325
  if (target && installed && compareVersions(installed, target) < 0) {
package/herdr-plugin.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  id = "herdr.remote.web"
2
2
  name = "Herdr Remote Web"
3
- version = "0.2.29"
3
+ version = "0.2.30"
4
4
  min_herdr_version = "0.9.1"
5
5
  description = "Browser access to Herdr workspaces via local or self-hosted relay"
6
6
  platforms = ["linux", "macos", "windows"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdr-remote",
3
- "version": "0.2.29",
3
+ "version": "0.2.30",
4
4
  "description": "Browser access to Herdr workspaces: plugin, host connector, and configuration TUI",
5
5
  "license": "MIT",
6
6
  "type": "module",