@flowscripter/dynamic-cli-framework 5.0.5 → 5.1.0
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/src/service/plugin/command/PluginAddSubCommand.d.ts.map +1 -1
- package/dist/src/service/plugin/command/PluginAddSubCommand.js +9 -3
- package/dist/src/service/plugin/command/parsePluginSpecifier.d.ts +17 -0
- package/dist/src/service/plugin/command/parsePluginSpecifier.d.ts.map +1 -0
- package/dist/src/service/plugin/command/parsePluginSpecifier.js +22 -0
- package/dist/src/service/upgrade/DefaultUpgradeService.d.ts.map +1 -1
- package/dist/src/service/upgrade/DefaultUpgradeService.js +29 -5
- package/package.json +1 -1
- package/src/service/plugin/command/PluginAddSubCommand.ts +10 -3
- package/src/service/plugin/command/parsePluginSpecifier.ts +22 -0
- package/src/service/upgrade/DefaultUpgradeService.ts +31 -5
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PluginAddSubCommand.d.ts","sourceRoot":"","sources":["../../../../../src/service/plugin/command/PluginAddSubCommand.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,yCAAyC,CAAC;AAC1E,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,yCAAyC,CAAC;AACvE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yCAAyC,CAAC;AACtE,OAAO,EAAE,aAAa,EAAE,MAAM,yCAAyC,CAAC;
|
|
1
|
+
{"version":3,"file":"PluginAddSubCommand.d.ts","sourceRoot":"","sources":["../../../../../src/service/plugin/command/PluginAddSubCommand.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,yCAAyC,CAAC;AAC1E,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,yCAAyC,CAAC;AACvE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yCAAyC,CAAC;AACtE,OAAO,EAAE,aAAa,EAAE,MAAM,yCAAyC,CAAC;AASxE,qBAAa,mBAAoB,YAAW,UAAU;IACpD,QAAQ,CAAC,IAAI,SAAS;IACtB,QAAQ,CAAC,WAAW,6BAA6B;IACjD,QAAQ,CAAC,mBAAmB,SAAS;IACrC,QAAQ,CAAC,OAAO,UAAM;IACtB,QAAQ,CAAC,WAAW;;;;QAMlB;IAEI,OAAO,CAAC,OAAO,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CA6CvE"}
|
|
@@ -2,6 +2,7 @@ import { ValueTypeName } from "@flowscripter/dynamic-cli-framework-api";
|
|
|
2
2
|
import { Icon, PRINTER_SERVICE_ID } from "@flowscripter/dynamic-cli-framework-api";
|
|
3
3
|
import { PLUGIN_SERVICE_ID } from "@flowscripter/dynamic-cli-framework-api";
|
|
4
4
|
import { getPluginId } from "./getPluginId.js";
|
|
5
|
+
import { parsePluginSpecifier } from "./parsePluginSpecifier.js";
|
|
5
6
|
export class PluginAddSubCommand {
|
|
6
7
|
name = "add";
|
|
7
8
|
description = "Install a remote plugin";
|
|
@@ -10,14 +11,14 @@ export class PluginAddSubCommand {
|
|
|
10
11
|
positionals = [
|
|
11
12
|
{
|
|
12
13
|
name: "pluginId",
|
|
13
|
-
description: "Plugin ID to install (e.g. @scope/name)",
|
|
14
|
+
description: "Plugin ID to install (e.g. @scope/name or @scope/name:version)",
|
|
14
15
|
type: ValueTypeName.STRING,
|
|
15
16
|
},
|
|
16
17
|
];
|
|
17
18
|
async execute(context, argumentValues) {
|
|
18
19
|
const printerService = context.getServiceById(PRINTER_SERVICE_ID);
|
|
19
20
|
const pluginService = context.getServiceById(PLUGIN_SERVICE_ID);
|
|
20
|
-
const pluginId = argumentValues["pluginId"];
|
|
21
|
+
const { pluginId, version } = parsePluginSpecifier(argumentValues["pluginId"]);
|
|
21
22
|
await printerService.info(`Searching for plugin: ${pluginId}\n`, Icon.INFORMATION);
|
|
22
23
|
let descriptor;
|
|
23
24
|
for await (const d of pluginService.search({ text: pluginId })) {
|
|
@@ -26,6 +27,11 @@ export class PluginAddSubCommand {
|
|
|
26
27
|
break;
|
|
27
28
|
}
|
|
28
29
|
}
|
|
30
|
+
if (descriptor && version) {
|
|
31
|
+
// search only ever returns the latest version - substitute the explicitly requested
|
|
32
|
+
// version so install() is asked for the correct one.
|
|
33
|
+
descriptor = { ...descriptor, version };
|
|
34
|
+
}
|
|
29
35
|
if (!descriptor) {
|
|
30
36
|
// Search did not find an exact match - attempt direct install by plugin ID.
|
|
31
37
|
// This handles cases where the package exists on the registry but is not
|
|
@@ -38,7 +44,7 @@ export class PluginAddSubCommand {
|
|
|
38
44
|
pluginId,
|
|
39
45
|
scope,
|
|
40
46
|
name,
|
|
41
|
-
version: "latest",
|
|
47
|
+
version: version ?? "latest",
|
|
42
48
|
extensionPoints: [],
|
|
43
49
|
};
|
|
44
50
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Splits a plugin specifier into a bare plugin ID and an optional version.
|
|
3
|
+
*
|
|
4
|
+
* The specifier is split on the *last* `:` character, if present. Scoped npm package names
|
|
5
|
+
* (e.g. `@scope/name`) never legitimately contain a literal `:`, so this split cannot collide
|
|
6
|
+
* with the scope/slash structure of the plugin ID itself.
|
|
7
|
+
*
|
|
8
|
+
* Everything after the last `:` is always treated as the requested version, including
|
|
9
|
+
* non-semver tags such as `latest` or `next` - no validation is performed on the tail.
|
|
10
|
+
*
|
|
11
|
+
* @param specifier raw plugin specifier, e.g. `@scope/name`, `@scope/name:3.0.0` or `name:latest`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function parsePluginSpecifier(specifier: string): {
|
|
14
|
+
pluginId: string;
|
|
15
|
+
version?: string;
|
|
16
|
+
};
|
|
17
|
+
//# sourceMappingURL=parsePluginSpecifier.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parsePluginSpecifier.d.ts","sourceRoot":"","sources":["../../../../../src/service/plugin/command/parsePluginSpecifier.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,GAAG;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,CAS9F"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Splits a plugin specifier into a bare plugin ID and an optional version.
|
|
3
|
+
*
|
|
4
|
+
* The specifier is split on the *last* `:` character, if present. Scoped npm package names
|
|
5
|
+
* (e.g. `@scope/name`) never legitimately contain a literal `:`, so this split cannot collide
|
|
6
|
+
* with the scope/slash structure of the plugin ID itself.
|
|
7
|
+
*
|
|
8
|
+
* Everything after the last `:` is always treated as the requested version, including
|
|
9
|
+
* non-semver tags such as `latest` or `next` - no validation is performed on the tail.
|
|
10
|
+
*
|
|
11
|
+
* @param specifier raw plugin specifier, e.g. `@scope/name`, `@scope/name:3.0.0` or `name:latest`.
|
|
12
|
+
*/
|
|
13
|
+
export function parsePluginSpecifier(specifier) {
|
|
14
|
+
const index = specifier.lastIndexOf(":");
|
|
15
|
+
if (index === -1) {
|
|
16
|
+
return { pluginId: specifier };
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
pluginId: specifier.slice(0, index),
|
|
20
|
+
version: specifier.slice(index + 1),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"DefaultUpgradeService.d.ts","sourceRoot":"","sources":["../../../../src/service/upgrade/DefaultUpgradeService.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,SAAS,EACT,YAAY,EAEZ,YAAY,EACZ,kBAAkB,EAClB,aAAa,EACd,MAAM,yCAAyC,CAAC;AACjD,OAAO,EACL,aAAa,EACb,aAAa,EACb,WAAW,EACX,KAAK,cAAc,EACpB,MAAM,yCAAyC,CAAC;AAEjD,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AAM1E,eAAO,MAAM,wBAAwB,MAAM,CAAC;AAkB5C,MAAM,CAAC,OAAO,OAAO,qBAAsB,YAAW,cAAc;;gBAO/C,MAAM,EAAE,sBAAsB,EAAE,SAAS,EAAE,SAAS;IAKhE,eAAe,CACpB,YAAY,EAAE,YAAY,GAAG,SAAS,EACtC,YAAY,EAAE,YAAY,GAAG,SAAS,GACrC,IAAI;IAKA,qBAAqB,CAAC,aAAa,UAAQ,GAAG,OAAO,CAAC,kBAAkB,CAAC;IA0BzE,QAAQ,IAAI,WAAW,GAAG,SAAS;IAanC,UAAU,IAAI,aAAa,GAAG,SAAS;IAWjC,mBAAmB,CAAC,EAAE,EAAE,WAAW,GAAG,OAAO,CAAC,aAAa,GAAG,SAAS,CAAC;IAgBxE,eAAe,CAC1B,UAAU,CAAC,EAAE,WAAW,EACxB,YAAY,CAAC,EAAE,aAAa,EAC5B,qBAAqB,CAAC,EAAE,aAAa,GACpC,OAAO,CAAC,kBAAkB,CAAC;IAwCjB,OAAO,CAClB,UAAU,CAAC,EAAE,WAAW,EACxB,YAAY,CAAC,EAAE,aAAa,EAC5B,qBAAqB,CAAC,EAAE,aAAa,GACpC,OAAO,CAAC,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"DefaultUpgradeService.d.ts","sourceRoot":"","sources":["../../../../src/service/upgrade/DefaultUpgradeService.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,SAAS,EACT,YAAY,EAEZ,YAAY,EACZ,kBAAkB,EAClB,aAAa,EACd,MAAM,yCAAyC,CAAC;AACjD,OAAO,EACL,aAAa,EACb,aAAa,EACb,WAAW,EACX,KAAK,cAAc,EACpB,MAAM,yCAAyC,CAAC;AAEjD,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AAM1E,eAAO,MAAM,wBAAwB,MAAM,CAAC;AAkB5C,MAAM,CAAC,OAAO,OAAO,qBAAsB,YAAW,cAAc;;gBAO/C,MAAM,EAAE,sBAAsB,EAAE,SAAS,EAAE,SAAS;IAKhE,eAAe,CACpB,YAAY,EAAE,YAAY,GAAG,SAAS,EACtC,YAAY,EAAE,YAAY,GAAG,SAAS,GACrC,IAAI;IAKA,qBAAqB,CAAC,aAAa,UAAQ,GAAG,OAAO,CAAC,kBAAkB,CAAC;IA0BzE,QAAQ,IAAI,WAAW,GAAG,SAAS;IAanC,UAAU,IAAI,aAAa,GAAG,SAAS;IAWjC,mBAAmB,CAAC,EAAE,EAAE,WAAW,GAAG,OAAO,CAAC,aAAa,GAAG,SAAS,CAAC;IAgBxE,eAAe,CAC1B,UAAU,CAAC,EAAE,WAAW,EACxB,YAAY,CAAC,EAAE,aAAa,EAC5B,qBAAqB,CAAC,EAAE,aAAa,GACpC,OAAO,CAAC,kBAAkB,CAAC;IAwCjB,OAAO,CAClB,UAAU,CAAC,EAAE,WAAW,EACxB,YAAY,CAAC,EAAE,aAAa,EAC5B,qBAAqB,CAAC,EAAE,aAAa,GACpC,OAAO,CAAC,aAAa,CAAC;CAsU1B"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import process from "node:process";
|
|
2
|
-
import { mkdtemp } from "node:fs/promises";
|
|
2
|
+
import { mkdtemp, rename, rm } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
5
|
import { InstallMethod, SupportedArch, SupportedOs, } from "@flowscripter/dynamic-cli-framework-api";
|
|
6
6
|
import semver from "semver";
|
|
7
7
|
// checkForUpgrade() runs opportunistically on every CLI invocation (via BannerServiceProvider),
|
|
@@ -330,7 +330,9 @@ export default class DefaultUpgradeService {
|
|
|
330
330
|
}
|
|
331
331
|
async #upgradeViaGithubRelease(os, arch) {
|
|
332
332
|
const { owner, repo, assetPattern } = this.#config.githubRelease;
|
|
333
|
-
|
|
333
|
+
// macOS release assets use "aarch64" rather than "arm64" for the arm64 build; x64 (including
|
|
334
|
+
// Intel Macs) always uses "x64" regardless of os.
|
|
335
|
+
const archLabel = arch === SupportedArch.X64 ? "x64" : os === SupportedOs.MACOS ? "aarch64" : "arm64";
|
|
334
336
|
const assetName = assetPattern.replace("{os}", OS_LABELS[os]).replace("{arch}", archLabel);
|
|
335
337
|
const url = `https://github.com/${owner}/${repo}/releases/latest/download/${assetName}`;
|
|
336
338
|
// longRunning: true gets cooperative Ctrl-C handling during what can be the slowest step of
|
|
@@ -355,6 +357,14 @@ export default class DefaultUpgradeService {
|
|
|
355
357
|
}
|
|
356
358
|
const extractedBinary = join(tmpDir, `${this.#cliConfig.name}.exe`);
|
|
357
359
|
const oldPath = `${currentExecutable}.old.exe`;
|
|
360
|
+
// Best-effort cleanup of a stale "<exe>.old.exe" left behind by a *previous* upgrade run.
|
|
361
|
+
// Windows won't let us delete the just-renamed-aside exe while this process still has it
|
|
362
|
+
// open/mapped - that can only happen once we're no longer holding it, i.e. at the start of
|
|
363
|
+
// the NEXT invocation, before we move today's running exe aside. Ignore failures: the file
|
|
364
|
+
// may not exist, or may still be locked (e.g. another instance still running).
|
|
365
|
+
await this.#spawnService.spawn(["cmd", "/c", "del", "/f", "/q", oldPath], {
|
|
366
|
+
mode: "ignore",
|
|
367
|
+
});
|
|
358
368
|
const moveResult = await this.#spawnService.spawn(["cmd", "/c", "move", "/y", currentExecutable, oldPath], { mode: "ignore" });
|
|
359
369
|
if (!moveResult.ok) {
|
|
360
370
|
throw new Error("Failed to move current executable aside");
|
|
@@ -372,8 +382,22 @@ export default class DefaultUpgradeService {
|
|
|
372
382
|
throw new Error("Failed to extract release archive");
|
|
373
383
|
}
|
|
374
384
|
const extractedBinary = join(tmpDir, this.#cliConfig.name);
|
|
375
|
-
|
|
376
|
-
|
|
385
|
+
// Extract into a staging file in the SAME directory as the running executable (not
|
|
386
|
+
// os.tmpdir(), which may be a different filesystem/mount), then atomically rename it over
|
|
387
|
+
// currentExecutable. This avoids ETXTBSY: the kernel refuses to open-for-write the inode
|
|
388
|
+
// mapped as a running process's text segment, but rename() only swaps the directory entry
|
|
389
|
+
// to point at a different inode - the running process keeps executing from its original,
|
|
390
|
+
// now-unlinked-but-still-open inode until it next execs/restarts.
|
|
391
|
+
const stagingDir = await mkdtemp(join(dirname(currentExecutable), ".upgrade-"));
|
|
392
|
+
try {
|
|
393
|
+
const stagingBinary = join(stagingDir, this.#cliConfig.name);
|
|
394
|
+
await Bun.write(stagingBinary, Bun.file(extractedBinary));
|
|
395
|
+
await this.#spawnService.spawn(["chmod", "+x", stagingBinary], { mode: "ignore" });
|
|
396
|
+
await rename(stagingBinary, currentExecutable);
|
|
397
|
+
}
|
|
398
|
+
finally {
|
|
399
|
+
await rm(stagingDir, { recursive: true, force: true });
|
|
400
|
+
}
|
|
377
401
|
}
|
|
378
402
|
}
|
|
379
403
|
}
|
package/package.json
CHANGED
|
@@ -8,6 +8,7 @@ import { PLUGIN_SERVICE_ID } from "@flowscripter/dynamic-cli-framework-api";
|
|
|
8
8
|
import type { PluginService } from "@flowscripter/dynamic-cli-framework-api";
|
|
9
9
|
import type { VersionedPluginDescriptor } from "@flowscripter/dynamic-plugin-framework";
|
|
10
10
|
import { getPluginId } from "./getPluginId.ts";
|
|
11
|
+
import { parsePluginSpecifier } from "./parsePluginSpecifier.ts";
|
|
11
12
|
|
|
12
13
|
export class PluginAddSubCommand implements SubCommand {
|
|
13
14
|
readonly name = "add";
|
|
@@ -17,7 +18,7 @@ export class PluginAddSubCommand implements SubCommand {
|
|
|
17
18
|
readonly positionals = [
|
|
18
19
|
{
|
|
19
20
|
name: "pluginId",
|
|
20
|
-
description: "Plugin ID to install (e.g. @scope/name)",
|
|
21
|
+
description: "Plugin ID to install (e.g. @scope/name or @scope/name:version)",
|
|
21
22
|
type: ValueTypeName.STRING,
|
|
22
23
|
},
|
|
23
24
|
];
|
|
@@ -26,7 +27,7 @@ export class PluginAddSubCommand implements SubCommand {
|
|
|
26
27
|
const printerService = context.getServiceById(PRINTER_SERVICE_ID) as PrinterService;
|
|
27
28
|
const pluginService = context.getServiceById(PLUGIN_SERVICE_ID) as PluginService;
|
|
28
29
|
|
|
29
|
-
const pluginId = argumentValues["pluginId"] as string;
|
|
30
|
+
const { pluginId, version } = parsePluginSpecifier(argumentValues["pluginId"] as string);
|
|
30
31
|
await printerService.info(`Searching for plugin: ${pluginId}\n`, Icon.INFORMATION);
|
|
31
32
|
|
|
32
33
|
let descriptor: VersionedPluginDescriptor | undefined;
|
|
@@ -37,6 +38,12 @@ export class PluginAddSubCommand implements SubCommand {
|
|
|
37
38
|
}
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
if (descriptor && version) {
|
|
42
|
+
// search only ever returns the latest version - substitute the explicitly requested
|
|
43
|
+
// version so install() is asked for the correct one.
|
|
44
|
+
descriptor = { ...descriptor, version };
|
|
45
|
+
}
|
|
46
|
+
|
|
40
47
|
if (!descriptor) {
|
|
41
48
|
// Search did not find an exact match - attempt direct install by plugin ID.
|
|
42
49
|
// This handles cases where the package exists on the registry but is not
|
|
@@ -52,7 +59,7 @@ export class PluginAddSubCommand implements SubCommand {
|
|
|
52
59
|
pluginId,
|
|
53
60
|
scope,
|
|
54
61
|
name,
|
|
55
|
-
version: "latest",
|
|
62
|
+
version: version ?? "latest",
|
|
56
63
|
extensionPoints: [],
|
|
57
64
|
};
|
|
58
65
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Splits a plugin specifier into a bare plugin ID and an optional version.
|
|
3
|
+
*
|
|
4
|
+
* The specifier is split on the *last* `:` character, if present. Scoped npm package names
|
|
5
|
+
* (e.g. `@scope/name`) never legitimately contain a literal `:`, so this split cannot collide
|
|
6
|
+
* with the scope/slash structure of the plugin ID itself.
|
|
7
|
+
*
|
|
8
|
+
* Everything after the last `:` is always treated as the requested version, including
|
|
9
|
+
* non-semver tags such as `latest` or `next` - no validation is performed on the tail.
|
|
10
|
+
*
|
|
11
|
+
* @param specifier raw plugin specifier, e.g. `@scope/name`, `@scope/name:3.0.0` or `name:latest`.
|
|
12
|
+
*/
|
|
13
|
+
export function parsePluginSpecifier(specifier: string): { pluginId: string; version?: string } {
|
|
14
|
+
const index = specifier.lastIndexOf(":");
|
|
15
|
+
if (index === -1) {
|
|
16
|
+
return { pluginId: specifier };
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
pluginId: specifier.slice(0, index),
|
|
20
|
+
version: specifier.slice(index + 1),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import process from "node:process";
|
|
2
|
-
import { mkdtemp } from "node:fs/promises";
|
|
2
|
+
import { mkdtemp, rename, rm } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
5
|
import type {
|
|
6
6
|
CLIConfig,
|
|
7
7
|
FetchService,
|
|
@@ -413,8 +413,10 @@ export default class DefaultUpgradeService implements UpgradeService {
|
|
|
413
413
|
|
|
414
414
|
async #upgradeViaGithubRelease(os: SupportedOs, arch: SupportedArch): Promise<void> {
|
|
415
415
|
const { owner, repo, assetPattern } = this.#config.githubRelease!;
|
|
416
|
+
// macOS release assets use "aarch64" rather than "arm64" for the arm64 build; x64 (including
|
|
417
|
+
// Intel Macs) always uses "x64" regardless of os.
|
|
416
418
|
const archLabel =
|
|
417
|
-
|
|
419
|
+
arch === SupportedArch.X64 ? "x64" : os === SupportedOs.MACOS ? "aarch64" : "arm64";
|
|
418
420
|
const assetName = assetPattern.replace("{os}", OS_LABELS[os]).replace("{arch}", archLabel);
|
|
419
421
|
const url = `https://github.com/${owner}/${repo}/releases/latest/download/${assetName}`;
|
|
420
422
|
|
|
@@ -446,6 +448,16 @@ export default class DefaultUpgradeService implements UpgradeService {
|
|
|
446
448
|
}
|
|
447
449
|
const extractedBinary = join(tmpDir, `${this.#cliConfig.name}.exe`);
|
|
448
450
|
const oldPath = `${currentExecutable}.old.exe`;
|
|
451
|
+
|
|
452
|
+
// Best-effort cleanup of a stale "<exe>.old.exe" left behind by a *previous* upgrade run.
|
|
453
|
+
// Windows won't let us delete the just-renamed-aside exe while this process still has it
|
|
454
|
+
// open/mapped - that can only happen once we're no longer holding it, i.e. at the start of
|
|
455
|
+
// the NEXT invocation, before we move today's running exe aside. Ignore failures: the file
|
|
456
|
+
// may not exist, or may still be locked (e.g. another instance still running).
|
|
457
|
+
await this.#spawnService!.spawn(["cmd", "/c", "del", "/f", "/q", oldPath], {
|
|
458
|
+
mode: "ignore",
|
|
459
|
+
});
|
|
460
|
+
|
|
449
461
|
const moveResult = await this.#spawnService!.spawn(
|
|
450
462
|
["cmd", "/c", "move", "/y", currentExecutable, oldPath],
|
|
451
463
|
{ mode: "ignore" },
|
|
@@ -471,8 +483,22 @@ export default class DefaultUpgradeService implements UpgradeService {
|
|
|
471
483
|
throw new Error("Failed to extract release archive");
|
|
472
484
|
}
|
|
473
485
|
const extractedBinary = join(tmpDir, this.#cliConfig.name);
|
|
474
|
-
|
|
475
|
-
|
|
486
|
+
|
|
487
|
+
// Extract into a staging file in the SAME directory as the running executable (not
|
|
488
|
+
// os.tmpdir(), which may be a different filesystem/mount), then atomically rename it over
|
|
489
|
+
// currentExecutable. This avoids ETXTBSY: the kernel refuses to open-for-write the inode
|
|
490
|
+
// mapped as a running process's text segment, but rename() only swaps the directory entry
|
|
491
|
+
// to point at a different inode - the running process keeps executing from its original,
|
|
492
|
+
// now-unlinked-but-still-open inode until it next execs/restarts.
|
|
493
|
+
const stagingDir = await mkdtemp(join(dirname(currentExecutable), ".upgrade-"));
|
|
494
|
+
try {
|
|
495
|
+
const stagingBinary = join(stagingDir, this.#cliConfig.name);
|
|
496
|
+
await Bun.write(stagingBinary, Bun.file(extractedBinary));
|
|
497
|
+
await this.#spawnService!.spawn(["chmod", "+x", stagingBinary], { mode: "ignore" });
|
|
498
|
+
await rename(stagingBinary, currentExecutable);
|
|
499
|
+
} finally {
|
|
500
|
+
await rm(stagingDir, { recursive: true, force: true });
|
|
501
|
+
}
|
|
476
502
|
}
|
|
477
503
|
}
|
|
478
504
|
}
|