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.
- package/dist/update-command.js +2 -1
- package/dist/updater/in-place.js +83 -0
- package/dist/updater.js +7 -2
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
package/dist/update-command.js
CHANGED
|
@@ -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' &&
|
|
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
|
-
|
|
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