@f5-sales-demo/xcsh 22.0.1 → 22.1.2
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 +2 -0
- package/package.json +8 -8
- package/src/cli/update-cli.ts +141 -429
- package/src/cli/update-recommendation.ts +299 -0
- package/src/commands/self-update.ts +10 -5
- package/src/commands/update.ts +15 -63
- package/src/extensibility/plugins/marketplace/builtin-marketplace-snapshot.ts +1 -1
- package/src/extensibility/plugins/marketplace/builtin-marketplace.provenance.json +3 -3
- package/src/internal-urls/api-catalog-qmd-index.generated.ts +2 -2
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/internal-urls/docs-index.generated.ts +2 -2
- package/src/locales/ar.json +0 -1
- package/src/locales/de.json +0 -1
- package/src/locales/en.json +0 -1
- package/src/locales/es.json +0 -1
- package/src/locales/fr.json +0 -1
- package/src/locales/hi.json +0 -1
- package/src/locales/it.json +0 -1
- package/src/locales/ja.json +0 -1
- package/src/locales/ko.json +0 -1
- package/src/locales/pt-br.json +0 -1
- package/src/locales/th.json +0 -1
- package/src/locales/zh-cn.json +0 -1
- package/src/locales/zh-tw.json +0 -1
- package/src/main.ts +11 -3
package/CHANGELOG.md
CHANGED
|
@@ -42,6 +42,8 @@
|
|
|
42
42
|
|
|
43
43
|
### Changed
|
|
44
44
|
|
|
45
|
+
- Standardized executable updates on `xcsh self-update`; `xcsh update` is now reserved for strict manifest resource updates ([#4487](https://github.com/f5-sales-demo/xcsh/issues/4487)).
|
|
46
|
+
|
|
45
47
|
- Added `power.sleepPrevention` with an `idle` default and cumulative `off`, `idle`, `display`, and
|
|
46
48
|
`system` modes for session-scoped sleep prevention on macOS, Linux, and Windows
|
|
47
49
|
([#3724](https://github.com/f5-sales-demo/xcsh/issues/3724)).
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/xcsh",
|
|
4
|
-
"version": "22.
|
|
4
|
+
"version": "22.1.2",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/f5-sales-demo/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -67,13 +67,13 @@
|
|
|
67
67
|
},
|
|
68
68
|
"dependencies": {
|
|
69
69
|
"@agentclientprotocol/sdk": "1.4.0",
|
|
70
|
-
"@f5-sales-demo/pi-agent-core": "22.
|
|
71
|
-
"@f5-sales-demo/pi-ai": "22.
|
|
72
|
-
"@f5-sales-demo/pi-natives": "22.
|
|
73
|
-
"@f5-sales-demo/pi-resource-management": "22.
|
|
74
|
-
"@f5-sales-demo/pi-tui": "22.
|
|
75
|
-
"@f5-sales-demo/pi-utils": "22.
|
|
76
|
-
"@f5-sales-demo/xcsh-stats": "22.
|
|
70
|
+
"@f5-sales-demo/pi-agent-core": "22.1.2",
|
|
71
|
+
"@f5-sales-demo/pi-ai": "22.1.2",
|
|
72
|
+
"@f5-sales-demo/pi-natives": "22.1.2",
|
|
73
|
+
"@f5-sales-demo/pi-resource-management": "22.1.2",
|
|
74
|
+
"@f5-sales-demo/pi-tui": "22.1.2",
|
|
75
|
+
"@f5-sales-demo/pi-utils": "22.1.2",
|
|
76
|
+
"@f5-sales-demo/xcsh-stats": "22.1.2",
|
|
77
77
|
"@mozilla/readability": "^0.6",
|
|
78
78
|
"@resvg/resvg-wasm": "2.6.2",
|
|
79
79
|
"@sinclair/typebox": "0.34.52",
|
package/src/cli/update-cli.ts
CHANGED
|
@@ -1,487 +1,199 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Update CLI command handler.
|
|
3
|
-
*
|
|
4
|
-
* Handles executable updates for explicit `xcsh self-update` and compatible `xcsh update` forms.
|
|
5
|
-
* Auto-detects the installation method (npm, brew, bun, or standalone binary)
|
|
6
|
-
* and updates through the appropriate channel.
|
|
7
|
-
*/
|
|
1
|
+
/** Context-aware executable update policy and standalone replacement. */
|
|
8
2
|
import * as fs from "node:fs";
|
|
9
|
-
import
|
|
3
|
+
import { realpath } from "node:fs/promises";
|
|
10
4
|
import { pipeline } from "node:stream/promises";
|
|
11
|
-
import {
|
|
12
|
-
import { $ } from "bun";
|
|
5
|
+
import { APP_NAME, isEnoent, VERSION } from "@f5-sales-demo/pi-utils";
|
|
13
6
|
import chalk from "chalk";
|
|
14
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
defaultInstallChannelDependencies,
|
|
9
|
+
formatUpdateRecommendation,
|
|
10
|
+
resolveInstallChannel,
|
|
11
|
+
type UpdateRecommendation,
|
|
12
|
+
} from "./update-recommendation";
|
|
15
13
|
|
|
16
14
|
const REPO = "f5-sales-demo/xcsh";
|
|
17
|
-
const PACKAGE = "@f5-sales-demo/xcsh";
|
|
18
15
|
|
|
19
|
-
interface ReleaseInfo {
|
|
16
|
+
export interface ReleaseInfo {
|
|
20
17
|
tag: string;
|
|
21
18
|
version: string;
|
|
22
19
|
}
|
|
23
20
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
return {
|
|
34
|
-
force: args.includes("--force") || args.includes("-f"),
|
|
35
|
-
check: args.includes("--check") || args.includes("-c"),
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
async function getBunGlobalBinDir(): Promise<string | undefined> {
|
|
40
|
-
if (!$which("bun")) return undefined;
|
|
41
|
-
try {
|
|
42
|
-
const result = await $`bun pm bin -g`.quiet().nothrow();
|
|
43
|
-
if (result.exitCode !== 0) return undefined;
|
|
44
|
-
const output = result.text().trim();
|
|
45
|
-
return output.length > 0 ? output : undefined;
|
|
46
|
-
} catch {
|
|
47
|
-
return undefined;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function normalizePathForComparison(filePath: string): string {
|
|
52
|
-
const normalized = path.normalize(filePath);
|
|
53
|
-
if (process.platform === "win32") return normalized.toLowerCase();
|
|
54
|
-
return normalized;
|
|
21
|
+
export interface UpdateCommandDependencies {
|
|
22
|
+
currentVersion: string;
|
|
23
|
+
getLatestRelease(): Promise<ReleaseInfo>;
|
|
24
|
+
resolveRecommendation(): Promise<UpdateRecommendation>;
|
|
25
|
+
updateStandalone(version: string): Promise<void>;
|
|
26
|
+
stdout(value: string): void;
|
|
27
|
+
stderr(value: string): void;
|
|
55
28
|
}
|
|
56
29
|
|
|
57
|
-
function isPathInDirectory(filePath: string, directoryPath: string): boolean {
|
|
58
|
-
const normalizedPath = normalizePathForComparison(path.resolve(filePath));
|
|
59
|
-
const normalizedDirectory = normalizePathForComparison(path.resolve(directoryPath));
|
|
60
|
-
const relativePath = path.relative(normalizedDirectory, normalizedPath);
|
|
61
|
-
return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export type InstallMethod = "npm" | "brew" | "bun" | "binary";
|
|
65
|
-
|
|
66
|
-
type UpdateTarget =
|
|
67
|
-
| { method: "npm"; path: string }
|
|
68
|
-
| { method: "brew"; path: string }
|
|
69
|
-
| { method: "bun" }
|
|
70
|
-
| { method: "binary"; path: string };
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Detect how xcsh was installed by examining the binary path.
|
|
74
|
-
*
|
|
75
|
-
* Detection order:
|
|
76
|
-
* 1. bun — binary is inside bun's global bin directory
|
|
77
|
-
* 2. npm — binary is a symlink whose resolution chain contains "node_modules"
|
|
78
|
-
* 3. brew — binary path or realpath contains "Cellar", "Caskroom", or "homebrew"
|
|
79
|
-
* 4. binary — fallback for standalone installs
|
|
80
|
-
*/
|
|
81
|
-
function detectInstallMethod(binPath: string, bunBinDir: string | undefined): InstallMethod {
|
|
82
|
-
// 1. Bun: binary lives inside bun's global bin dir
|
|
83
|
-
if (bunBinDir && isPathInDirectory(binPath, bunBinDir)) {
|
|
84
|
-
return "bun";
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// 2. npm: binary is a symlink whose target chain contains node_modules
|
|
88
|
-
try {
|
|
89
|
-
const stats = fs.lstatSync(binPath);
|
|
90
|
-
if (stats.isSymbolicLink()) {
|
|
91
|
-
const linkTarget = fs.readlinkSync(binPath);
|
|
92
|
-
const resolvedTarget = path.resolve(path.dirname(binPath), linkTarget);
|
|
93
|
-
if (linkTarget.includes("node_modules") || resolvedTarget.includes("node_modules")) {
|
|
94
|
-
return "npm";
|
|
95
|
-
}
|
|
96
|
-
try {
|
|
97
|
-
const realPath = fs.realpathSync(binPath);
|
|
98
|
-
if (realPath.includes("node_modules")) {
|
|
99
|
-
return "npm";
|
|
100
|
-
}
|
|
101
|
-
} catch {
|
|
102
|
-
// realpath may fail if target doesn't exist
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
} catch {
|
|
106
|
-
// lstat/readlink may fail; fall through
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// 3. brew: path or realpath contains Cellar, Caskroom, or homebrew
|
|
110
|
-
const lowerBinPath = binPath.toLowerCase();
|
|
111
|
-
if (
|
|
112
|
-
lowerBinPath.includes("/cellar/") ||
|
|
113
|
-
lowerBinPath.includes("/caskroom/") ||
|
|
114
|
-
lowerBinPath.includes("/homebrew/")
|
|
115
|
-
) {
|
|
116
|
-
return "brew";
|
|
117
|
-
}
|
|
118
|
-
try {
|
|
119
|
-
const realPath = fs.realpathSync(binPath).toLowerCase();
|
|
120
|
-
if (realPath.includes("/cellar/") || realPath.includes("/caskroom/") || realPath.includes("/homebrew/")) {
|
|
121
|
-
return "brew";
|
|
122
|
-
}
|
|
123
|
-
} catch {
|
|
124
|
-
// realpath may fail; fall through
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// 4. Standalone binary (fallback)
|
|
128
|
-
return "binary";
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function resolveUpdateMethod(
|
|
132
|
-
ompPath: string,
|
|
133
|
-
bunBinDir: string | undefined,
|
|
134
|
-
distributionChannel = process.env.XCSH_DISTRIBUTION_CHANNEL,
|
|
135
|
-
): InstallMethod {
|
|
136
|
-
if (distributionChannel === "bun" || distributionChannel === "npm") return distributionChannel;
|
|
137
|
-
return detectInstallMethod(ompPath, bunBinDir);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export function _resolveUpdateMethodForTest(
|
|
141
|
-
ompPath: string,
|
|
142
|
-
bunBinDir: string | undefined,
|
|
143
|
-
distributionChannel?: string,
|
|
144
|
-
): InstallMethod {
|
|
145
|
-
return resolveUpdateMethod(ompPath, bunBinDir, distributionChannel);
|
|
146
|
-
}
|
|
147
|
-
async function resolveUpdateTarget(): Promise<UpdateTarget> {
|
|
148
|
-
const bunBinDir = await getBunGlobalBinDir();
|
|
149
|
-
const ompPath = resolveOmpPath();
|
|
150
|
-
|
|
151
|
-
if (ompPath) {
|
|
152
|
-
const method = resolveUpdateMethod(ompPath, bunBinDir);
|
|
153
|
-
if (method === "bun") return { method };
|
|
154
|
-
return { method, path: ompPath };
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
if (bunBinDir) return { method: "bun" };
|
|
158
|
-
|
|
159
|
-
throw new Error(`Could not resolve ${APP_NAME} binary path in PATH`);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Get the latest release info from the npm registry.
|
|
164
|
-
* Uses npm instead of GitHub API to avoid unauthenticated rate limiting.
|
|
165
|
-
*/
|
|
166
30
|
async function getLatestRelease(): Promise<ReleaseInfo> {
|
|
167
|
-
const response = await fetch(
|
|
168
|
-
if (!response.ok) {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const data = (await response.json()) as { version: string };
|
|
173
|
-
const version = data.version;
|
|
174
|
-
const tag = `v${version}`;
|
|
175
|
-
|
|
176
|
-
return {
|
|
177
|
-
tag,
|
|
178
|
-
version,
|
|
179
|
-
};
|
|
31
|
+
const response = await fetch("https://registry.npmjs.org/@f5-sales-demo/xcsh/latest");
|
|
32
|
+
if (!response.ok) throw new Error(`Failed to fetch release info: ${response.statusText}`);
|
|
33
|
+
const data = (await response.json()) as { version?: string };
|
|
34
|
+
if (!data.version) throw new Error("Latest release response did not include a version");
|
|
35
|
+
return { tag: `v${data.version}`, version: data.version };
|
|
180
36
|
}
|
|
181
37
|
|
|
182
|
-
/**
|
|
183
|
-
* Compare semver versions. Returns:
|
|
184
|
-
* - negative if a < b
|
|
185
|
-
* - 0 if a == b
|
|
186
|
-
* - positive if a > b
|
|
187
|
-
*/
|
|
188
38
|
function compareVersions(a: string, b: string): number {
|
|
189
39
|
const pa = a.split(".").map(Number);
|
|
190
40
|
const pb = b.split(".").map(Number);
|
|
191
|
-
|
|
192
41
|
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
if (na !== nb) return na - nb;
|
|
42
|
+
const difference = (pa[i] || 0) - (pb[i] || 0);
|
|
43
|
+
if (difference !== 0) return difference;
|
|
196
44
|
}
|
|
197
45
|
return 0;
|
|
198
46
|
}
|
|
199
47
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const platform = process.platform;
|
|
205
|
-
const arch = process.arch;
|
|
206
|
-
|
|
207
|
-
let os: string;
|
|
208
|
-
switch (platform) {
|
|
209
|
-
case "linux":
|
|
210
|
-
os = "linux";
|
|
211
|
-
break;
|
|
212
|
-
case "darwin":
|
|
213
|
-
os = "darwin";
|
|
214
|
-
break;
|
|
215
|
-
case "win32":
|
|
216
|
-
os = "windows";
|
|
217
|
-
break;
|
|
218
|
-
default:
|
|
219
|
-
throw new Error(`Unsupported platform: ${platform}`);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
let archName: string;
|
|
223
|
-
switch (arch) {
|
|
224
|
-
case "x64":
|
|
225
|
-
archName = "x64";
|
|
226
|
-
break;
|
|
227
|
-
case "arm64":
|
|
228
|
-
archName = "arm64";
|
|
229
|
-
break;
|
|
230
|
-
default:
|
|
231
|
-
throw new Error(`Unsupported architecture: ${arch}`);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
if (os === "windows") {
|
|
235
|
-
return `${APP_NAME}-${os}-${archName}.exe`;
|
|
236
|
-
}
|
|
237
|
-
return `${APP_NAME}-${os}-${archName}`;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Resolve the path that `xcsh` maps to in the user's PATH.
|
|
242
|
-
*/
|
|
243
|
-
function resolveOmpPath(): string | undefined {
|
|
244
|
-
return $which(APP_NAME) ?? undefined;
|
|
48
|
+
function getBinaryName(platform = process.platform, arch = process.arch): string {
|
|
49
|
+
if (platform === "linux" && (arch === "x64" || arch === "arm64")) return `${APP_NAME}-linux-${arch}`;
|
|
50
|
+
if (platform === "darwin" && (arch === "x64" || arch === "arm64")) return `${APP_NAME}-darwin-${arch}`;
|
|
51
|
+
throw new Error(`Unsupported standalone update platform: ${platform}/${arch}`);
|
|
245
52
|
}
|
|
246
53
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
expectedVersion
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
try {
|
|
257
|
-
const result = await $`${ompPath} --version`.quiet().nothrow();
|
|
258
|
-
if (result.exitCode !== 0) return { ok: false, path: ompPath };
|
|
259
|
-
const output = result.text().trim();
|
|
260
|
-
// Output format: "xcsh/X.Y.Z"
|
|
261
|
-
const match = output.match(/\/(\d+\.\d+\.\d+)/);
|
|
262
|
-
const actual = match?.[1];
|
|
263
|
-
return { ok: actual === expectedVersion, actual, path: ompPath };
|
|
264
|
-
} catch {
|
|
265
|
-
return { ok: false, path: ompPath };
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* Print post-update verification result.
|
|
271
|
-
*/
|
|
272
|
-
async function printVerification(expectedVersion: string, explicitPath?: string): Promise<void> {
|
|
273
|
-
const result = await verifyInstalledVersion(expectedVersion, explicitPath);
|
|
274
|
-
if (result.ok) {
|
|
275
|
-
console.log(chalk.green(`\n${theme.status.success} Updated to ${expectedVersion}`));
|
|
276
|
-
return;
|
|
277
|
-
}
|
|
278
|
-
if (result.actual) {
|
|
279
|
-
console.log(
|
|
280
|
-
chalk.yellow(
|
|
281
|
-
`\nWarning: ${APP_NAME} at ${result.path} still reports ${result.actual} (expected ${expectedVersion})`,
|
|
282
|
-
),
|
|
283
|
-
);
|
|
284
|
-
} else {
|
|
285
|
-
console.log(
|
|
286
|
-
chalk.yellow(`\nWarning: could not verify updated version${result.path ? ` at ${result.path}` : ""}`),
|
|
54
|
+
async function validateExecutableVersion(executable: string, expectedVersion: string): Promise<void> {
|
|
55
|
+
const child = Bun.spawn([executable, "--version"], { stdin: "ignore", stdout: "pipe", stderr: "pipe" });
|
|
56
|
+
const [exitCode, output] = await Promise.all([child.exited, new Response(child.stdout).text()]);
|
|
57
|
+
const actual = output.match(/\/(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/u)?.[1];
|
|
58
|
+
if (exitCode !== 0 || actual !== expectedVersion) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
actual
|
|
61
|
+
? `${APP_NAME} at ${executable} reports ${actual}; expected ${expectedVersion}`
|
|
62
|
+
: `${APP_NAME} at ${executable} could not be verified as ${expectedVersion}`,
|
|
287
63
|
);
|
|
288
64
|
}
|
|
289
|
-
console.log(
|
|
290
|
-
chalk.yellow(
|
|
291
|
-
`You may need to reinstall: curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | bash`,
|
|
292
|
-
),
|
|
293
|
-
);
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
/**
|
|
297
|
-
* Update via bun package manager.
|
|
298
|
-
*/
|
|
299
|
-
async function updateViaBun(expectedVersion: string): Promise<void> {
|
|
300
|
-
console.log(chalk.dim("Updating via bun..."));
|
|
301
|
-
const result = await $`bun install -g ${PACKAGE}@${expectedVersion}`.nothrow();
|
|
302
|
-
if (result.exitCode !== 0) {
|
|
303
|
-
throw new Error(`bun install failed with exit code ${result.exitCode}`);
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
await printVerification(expectedVersion);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* Update via npm package manager.
|
|
311
|
-
*/
|
|
312
|
-
async function updateViaNpm(expectedVersion: string): Promise<void> {
|
|
313
|
-
console.log(chalk.dim("Updating via npm..."));
|
|
314
|
-
const result = await $`npm install -g ${PACKAGE}@${expectedVersion}`.nothrow();
|
|
315
|
-
if (result.exitCode !== 0) {
|
|
316
|
-
throw new Error(`npm install failed with exit code ${result.exitCode}`);
|
|
317
|
-
}
|
|
318
65
|
}
|
|
319
66
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
console.log(chalk.yellow(`\n${APP_NAME} at ${targetPath} was installed via Homebrew.`));
|
|
328
|
-
console.log(chalk.yellow(`To update to ${expectedVersion}, run:`));
|
|
329
|
-
console.log(chalk.cyan(` ${getBrewUpgradeCommand()}`));
|
|
330
|
-
console.log(chalk.dim("\nThis ensures the update goes through your organization's Homebrew tap."));
|
|
331
|
-
// #1874 Task 7: brew upgrade runs out-of-band, so we can't recycle automatically.
|
|
332
|
-
// Nudge the one command that applies it to the Chrome extension immediately.
|
|
333
|
-
console.log(chalk.dim(`Then run ${APP_NAME} chrome recycle to apply it to the extension now`));
|
|
334
|
-
console.log(chalk.dim("(otherwise it takes effect the next time you open Chrome)."));
|
|
67
|
+
export interface StandaloneReplacementOptions {
|
|
68
|
+
targetPath: string;
|
|
69
|
+
platform?: NodeJS.Platform;
|
|
70
|
+
arch?: NodeJS.Architecture;
|
|
71
|
+
fetchImpl?(input: string | URL | Request, init?: RequestInit): Promise<Response>;
|
|
72
|
+
rename?(source: string, destination: string): Promise<void>;
|
|
73
|
+
validate?(executable: string, expectedVersion: string): Promise<void>;
|
|
335
74
|
}
|
|
336
75
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
const
|
|
346
|
-
const tag = `v${expectedVersion}`;
|
|
347
|
-
const url = `https://github.com/${REPO}/releases/download/${tag}/${binaryName}`;
|
|
348
|
-
|
|
349
|
-
const tempPath = `${targetPath}.new`;
|
|
76
|
+
/** Atomically replace one proven standalone executable, restoring it on every failure. */
|
|
77
|
+
export async function replaceStandaloneExecutable(
|
|
78
|
+
expectedVersion: string,
|
|
79
|
+
options: StandaloneReplacementOptions,
|
|
80
|
+
): Promise<void> {
|
|
81
|
+
const targetPath = options.targetPath;
|
|
82
|
+
const binaryName = getBinaryName(options.platform, options.arch);
|
|
83
|
+
const url = `https://github.com/${REPO}/releases/download/v${expectedVersion}/${binaryName}`;
|
|
84
|
+
const tempPath = `${targetPath}.new-${process.pid}`;
|
|
350
85
|
const backupPath = `${targetPath}.bak`;
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
throw new Error(`Download failed: ${response.statusText}`);
|
|
356
|
-
}
|
|
357
|
-
const fileStream = fs.createWriteStream(tempPath, { mode: 0o755 });
|
|
358
|
-
await pipeline(response.body, fileStream);
|
|
86
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
87
|
+
const rename = options.rename ?? fs.promises.rename;
|
|
88
|
+
const validate = options.validate ?? validateExecutableVersion;
|
|
89
|
+
let originalMoved = false;
|
|
359
90
|
|
|
360
|
-
console.log(chalk.dim("Installing update..."));
|
|
361
91
|
try {
|
|
92
|
+
const response = await fetchImpl(url, { redirect: "follow" });
|
|
93
|
+
if (!response.ok || !response.body) throw new Error(`Download failed: ${response.statusText}`);
|
|
94
|
+
await pipeline(response.body, fs.createWriteStream(tempPath, { mode: 0o755, flags: "wx" }));
|
|
95
|
+
await fs.promises.chmod(tempPath, 0o755);
|
|
96
|
+
await validate(tempPath, expectedVersion);
|
|
97
|
+
|
|
362
98
|
try {
|
|
363
99
|
await fs.promises.unlink(backupPath);
|
|
364
|
-
} catch (
|
|
365
|
-
if (!isEnoent(
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (!isEnoent(error)) throw error;
|
|
366
102
|
}
|
|
367
|
-
await
|
|
368
|
-
|
|
103
|
+
await rename(targetPath, backupPath);
|
|
104
|
+
originalMoved = true;
|
|
105
|
+
await rename(tempPath, targetPath);
|
|
106
|
+
await validate(targetPath, expectedVersion);
|
|
369
107
|
await fs.promises.unlink(backupPath);
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
108
|
+
originalMoved = false;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (originalMoved) {
|
|
111
|
+
try {
|
|
112
|
+
await fs.promises.unlink(targetPath);
|
|
113
|
+
} catch (unlinkError) {
|
|
114
|
+
if (!isEnoent(unlinkError)) throw unlinkError;
|
|
115
|
+
}
|
|
116
|
+
await rename(backupPath, targetPath);
|
|
117
|
+
originalMoved = false;
|
|
376
118
|
}
|
|
377
|
-
|
|
119
|
+
throw error;
|
|
120
|
+
} finally {
|
|
121
|
+
try {
|
|
378
122
|
await fs.promises.unlink(tempPath);
|
|
379
|
-
}
|
|
380
|
-
throw err;
|
|
123
|
+
} catch {}
|
|
381
124
|
}
|
|
382
125
|
}
|
|
383
126
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
export async function runUpdateCommand(opts: { force: boolean; check: boolean }): Promise<void> {
|
|
388
|
-
console.log(chalk.dim(`Current version: ${VERSION}`));
|
|
127
|
+
export async function updateStandaloneExecutable(expectedVersion: string): Promise<void> {
|
|
128
|
+
return replaceStandaloneExecutable(expectedVersion, { targetPath: await realpath(process.execPath) });
|
|
129
|
+
}
|
|
389
130
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
131
|
+
function defaultUpdateCommandDependencies(): UpdateCommandDependencies {
|
|
132
|
+
return {
|
|
133
|
+
currentVersion: VERSION,
|
|
134
|
+
getLatestRelease,
|
|
135
|
+
resolveRecommendation: () => resolveInstallChannel(defaultInstallChannelDependencies(VERSION)),
|
|
136
|
+
updateStandalone: updateStandaloneExecutable,
|
|
137
|
+
stdout: value => console.log(value),
|
|
138
|
+
stderr: value => console.error(value),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
398
141
|
|
|
399
|
-
|
|
142
|
+
/** Run the executable self-update policy. */
|
|
143
|
+
export async function runUpdateCommand(
|
|
144
|
+
opts: { force: boolean; check: boolean },
|
|
145
|
+
deps: UpdateCommandDependencies = defaultUpdateCommandDependencies(),
|
|
146
|
+
): Promise<number> {
|
|
147
|
+
deps.stdout(chalk.dim(`Current version: ${deps.currentVersion}`));
|
|
400
148
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
149
|
+
let recommendation: UpdateRecommendation;
|
|
150
|
+
try {
|
|
151
|
+
recommendation = await deps.resolveRecommendation();
|
|
152
|
+
} catch (error) {
|
|
153
|
+
deps.stderr(chalk.red(`Update blocked: installation provenance check failed: ${error}`));
|
|
154
|
+
return 1;
|
|
155
|
+
}
|
|
156
|
+
deps.stdout(chalk.dim(`Detected channel: ${recommendation.channel}`));
|
|
157
|
+
if (recommendation.action === "blocked") {
|
|
158
|
+
deps.stderr(chalk.red(formatUpdateRecommendation(recommendation)));
|
|
159
|
+
return 1;
|
|
404
160
|
}
|
|
405
161
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
162
|
+
let release: ReleaseInfo;
|
|
163
|
+
try {
|
|
164
|
+
release = await deps.getLatestRelease();
|
|
165
|
+
} catch (error) {
|
|
166
|
+
deps.stderr(chalk.red(`Failed to check for updates: ${error}`));
|
|
167
|
+
return 1;
|
|
410
168
|
}
|
|
411
169
|
|
|
170
|
+
const comparison = compareVersions(release.version, deps.currentVersion);
|
|
171
|
+
if (comparison > 0) deps.stdout(chalk.cyan(`New version available: ${release.version}`));
|
|
172
|
+
else if (opts.force) deps.stdout(chalk.yellow(`Forcing update recommendation for ${release.version}`));
|
|
173
|
+
else deps.stdout(chalk.green("✓ Already up to date"));
|
|
174
|
+
|
|
412
175
|
if (opts.check) {
|
|
413
|
-
|
|
414
|
-
return;
|
|
176
|
+
deps.stdout(formatUpdateRecommendation(recommendation));
|
|
177
|
+
return 0;
|
|
415
178
|
}
|
|
416
179
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
switch (target.method) {
|
|
423
|
-
case "bun":
|
|
424
|
-
await updateViaBun(release.version);
|
|
425
|
-
break;
|
|
426
|
-
case "npm":
|
|
427
|
-
await updateViaNpm(release.version);
|
|
428
|
-
await printVerification(release.version);
|
|
429
|
-
break;
|
|
430
|
-
case "brew":
|
|
431
|
-
updateViaBrew(target.path, release.version);
|
|
432
|
-
return;
|
|
433
|
-
case "binary":
|
|
434
|
-
await updateViaBinaryAt(target.path, release.version);
|
|
435
|
-
break;
|
|
436
|
-
}
|
|
180
|
+
if (recommendation.action !== "self-update") {
|
|
181
|
+
deps.stdout(formatUpdateRecommendation(recommendation));
|
|
182
|
+
return 0;
|
|
183
|
+
}
|
|
184
|
+
if (comparison <= 0 && !opts.force) return 0;
|
|
437
185
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
} catch {
|
|
447
|
-
/* recycle is a convenience — never fail an update on it */
|
|
448
|
-
}
|
|
449
|
-
} catch (err) {
|
|
450
|
-
console.error(chalk.red(`Update failed: ${err}`));
|
|
451
|
-
process.exit(1);
|
|
186
|
+
try {
|
|
187
|
+
await deps.updateStandalone(release.version);
|
|
188
|
+
deps.stdout(chalk.green(`✓ Updated to ${release.version}`));
|
|
189
|
+
deps.stdout(chalk.dim(`Restart ${APP_NAME} to use the new version`));
|
|
190
|
+
return 0;
|
|
191
|
+
} catch (error) {
|
|
192
|
+
deps.stderr(chalk.red(`Update failed: ${error}`));
|
|
193
|
+
return 1;
|
|
452
194
|
}
|
|
453
195
|
}
|
|
454
196
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
*/
|
|
458
|
-
export function printUpdateHelp(): void {
|
|
459
|
-
console.log(`${chalk.bold(`${APP_NAME} update`)} - Update the executable or existing resources from manifests
|
|
460
|
-
|
|
461
|
-
${chalk.bold("Usage:")}
|
|
462
|
-
${APP_NAME} update [--check | --force]
|
|
463
|
-
${APP_NAME} update -f <manifest> [resource options]
|
|
464
|
-
|
|
465
|
-
${chalk.bold("Executable options:")}
|
|
466
|
-
-c, --check Check for executable updates without installing
|
|
467
|
-
--force Force reinstall even if up to date
|
|
468
|
-
|
|
469
|
-
${chalk.bold("Resource input:")}
|
|
470
|
-
-f, --filename <manifest> Update resources from a manifest
|
|
471
|
-
|
|
472
|
-
Short -f means --filename for xcsh update. Use xcsh self-update -f for the
|
|
473
|
-
short executable force form. Executable and resource flags cannot be mixed.
|
|
474
|
-
|
|
475
|
-
${chalk.bold("Install methods (auto-detected):")}
|
|
476
|
-
npm Installed via npm install -g
|
|
477
|
-
brew Installed via Homebrew (prints upgrade instructions)
|
|
478
|
-
bun Installed via bun install -g
|
|
479
|
-
binary Standalone binary (direct download)
|
|
480
|
-
|
|
481
|
-
${chalk.bold("Examples:")}
|
|
482
|
-
${APP_NAME} update Update to latest version
|
|
483
|
-
${APP_NAME} update --check Check if updates are available
|
|
484
|
-
${APP_NAME} update --force Force reinstall
|
|
485
|
-
${APP_NAME} update -f manifest.yaml Update resources
|
|
486
|
-
`);
|
|
197
|
+
export function getBrewUpgradeCommand(): string {
|
|
198
|
+
return "brew upgrade --cask f5-sales-demo/tap/xcsh";
|
|
487
199
|
}
|