@f5-sales-demo/xcsh 22.0.0 → 22.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/package.json +8 -8
- package/src/cli/update-cli.ts +153 -412
- package/src/cli/update-recommendation.ts +299 -0
- package/src/commands/self-update.ts +10 -5
- package/src/commands/update.ts +3 -2
- 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/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.0",
|
|
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.0",
|
|
71
|
+
"@f5-sales-demo/pi-ai": "22.1.0",
|
|
72
|
+
"@f5-sales-demo/pi-natives": "22.1.0",
|
|
73
|
+
"@f5-sales-demo/pi-resource-management": "22.1.0",
|
|
74
|
+
"@f5-sales-demo/pi-tui": "22.1.0",
|
|
75
|
+
"@f5-sales-demo/pi-utils": "22.1.0",
|
|
76
|
+
"@f5-sales-demo/xcsh-stats": "22.1.0",
|
|
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,228 @@
|
|
|
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
|
-
|
|
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;
|
|
28
|
+
}
|
|
32
29
|
|
|
30
|
+
export function parseUpdateArgs(args: string[]): { force: boolean; check: boolean } | undefined {
|
|
31
|
+
if (args.length === 0 || args[0] !== "self-update") return undefined;
|
|
33
32
|
return {
|
|
34
33
|
force: args.includes("--force") || args.includes("-f"),
|
|
35
34
|
check: args.includes("--check") || args.includes("-c"),
|
|
36
35
|
};
|
|
37
36
|
}
|
|
38
37
|
|
|
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;
|
|
55
|
-
}
|
|
56
|
-
|
|
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
38
|
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
|
-
};
|
|
39
|
+
const response = await fetch("https://registry.npmjs.org/@f5-sales-demo/xcsh/latest");
|
|
40
|
+
if (!response.ok) throw new Error(`Failed to fetch release info: ${response.statusText}`);
|
|
41
|
+
const data = (await response.json()) as { version?: string };
|
|
42
|
+
if (!data.version) throw new Error("Latest release response did not include a version");
|
|
43
|
+
return { tag: `v${data.version}`, version: data.version };
|
|
180
44
|
}
|
|
181
45
|
|
|
182
|
-
/**
|
|
183
|
-
* Compare semver versions. Returns:
|
|
184
|
-
* - negative if a < b
|
|
185
|
-
* - 0 if a == b
|
|
186
|
-
* - positive if a > b
|
|
187
|
-
*/
|
|
188
46
|
function compareVersions(a: string, b: string): number {
|
|
189
47
|
const pa = a.split(".").map(Number);
|
|
190
48
|
const pb = b.split(".").map(Number);
|
|
191
|
-
|
|
192
49
|
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
if (na !== nb) return na - nb;
|
|
50
|
+
const difference = (pa[i] || 0) - (pb[i] || 0);
|
|
51
|
+
if (difference !== 0) return difference;
|
|
196
52
|
}
|
|
197
53
|
return 0;
|
|
198
54
|
}
|
|
199
55
|
|
|
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;
|
|
56
|
+
function getBinaryName(platform = process.platform, arch = process.arch): string {
|
|
57
|
+
if (platform === "linux" && (arch === "x64" || arch === "arm64")) return `${APP_NAME}-linux-${arch}`;
|
|
58
|
+
if (platform === "darwin" && (arch === "x64" || arch === "arm64")) return `${APP_NAME}-darwin-${arch}`;
|
|
59
|
+
throw new Error(`Unsupported standalone update platform: ${platform}/${arch}`);
|
|
245
60
|
}
|
|
246
61
|
|
|
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
|
-
),
|
|
62
|
+
async function validateExecutableVersion(executable: string, expectedVersion: string): Promise<void> {
|
|
63
|
+
const child = Bun.spawn([executable, "--version"], { stdin: "ignore", stdout: "pipe", stderr: "pipe" });
|
|
64
|
+
const [exitCode, output] = await Promise.all([child.exited, new Response(child.stdout).text()]);
|
|
65
|
+
const actual = output.match(/\/(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/u)?.[1];
|
|
66
|
+
if (exitCode !== 0 || actual !== expectedVersion) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
actual
|
|
69
|
+
? `${APP_NAME} at ${executable} reports ${actual}; expected ${expectedVersion}`
|
|
70
|
+
: `${APP_NAME} at ${executable} could not be verified as ${expectedVersion}`,
|
|
283
71
|
);
|
|
284
|
-
} else {
|
|
285
|
-
console.log(
|
|
286
|
-
chalk.yellow(`\nWarning: could not verify updated version${result.path ? ` at ${result.path}` : ""}`),
|
|
287
|
-
);
|
|
288
|
-
}
|
|
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
72
|
}
|
|
318
73
|
}
|
|
319
74
|
|
|
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)."));
|
|
75
|
+
export interface StandaloneReplacementOptions {
|
|
76
|
+
targetPath: string;
|
|
77
|
+
platform?: NodeJS.Platform;
|
|
78
|
+
arch?: NodeJS.Architecture;
|
|
79
|
+
fetchImpl?(input: string | URL | Request, init?: RequestInit): Promise<Response>;
|
|
80
|
+
rename?(source: string, destination: string): Promise<void>;
|
|
81
|
+
validate?(executable: string, expectedVersion: string): Promise<void>;
|
|
335
82
|
}
|
|
336
83
|
|
|
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`;
|
|
84
|
+
/** Atomically replace one proven standalone executable, restoring it on every failure. */
|
|
85
|
+
export async function replaceStandaloneExecutable(
|
|
86
|
+
expectedVersion: string,
|
|
87
|
+
options: StandaloneReplacementOptions,
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
const targetPath = options.targetPath;
|
|
90
|
+
const binaryName = getBinaryName(options.platform, options.arch);
|
|
91
|
+
const url = `https://github.com/${REPO}/releases/download/v${expectedVersion}/${binaryName}`;
|
|
92
|
+
const tempPath = `${targetPath}.new-${process.pid}`;
|
|
350
93
|
const backupPath = `${targetPath}.bak`;
|
|
351
|
-
|
|
94
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
95
|
+
const rename = options.rename ?? fs.promises.rename;
|
|
96
|
+
const validate = options.validate ?? validateExecutableVersion;
|
|
97
|
+
let originalMoved = false;
|
|
352
98
|
|
|
353
|
-
const response = await fetch(url, { redirect: "follow" });
|
|
354
|
-
if (!response.ok || !response.body) {
|
|
355
|
-
throw new Error(`Download failed: ${response.statusText}`);
|
|
356
|
-
}
|
|
357
|
-
const fileStream = fs.createWriteStream(tempPath, { mode: 0o755 });
|
|
358
|
-
await pipeline(response.body, fileStream);
|
|
359
|
-
|
|
360
|
-
console.log(chalk.dim("Installing update..."));
|
|
361
99
|
try {
|
|
100
|
+
const response = await fetchImpl(url, { redirect: "follow" });
|
|
101
|
+
if (!response.ok || !response.body) throw new Error(`Download failed: ${response.statusText}`);
|
|
102
|
+
await pipeline(response.body, fs.createWriteStream(tempPath, { mode: 0o755, flags: "wx" }));
|
|
103
|
+
await fs.promises.chmod(tempPath, 0o755);
|
|
104
|
+
await validate(tempPath, expectedVersion);
|
|
105
|
+
|
|
362
106
|
try {
|
|
363
107
|
await fs.promises.unlink(backupPath);
|
|
364
|
-
} catch (
|
|
365
|
-
if (!isEnoent(
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (!isEnoent(error)) throw error;
|
|
366
110
|
}
|
|
367
|
-
await
|
|
368
|
-
|
|
111
|
+
await rename(targetPath, backupPath);
|
|
112
|
+
originalMoved = true;
|
|
113
|
+
await rename(tempPath, targetPath);
|
|
114
|
+
await validate(targetPath, expectedVersion);
|
|
369
115
|
await fs.promises.unlink(backupPath);
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
116
|
+
originalMoved = false;
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (originalMoved) {
|
|
119
|
+
try {
|
|
120
|
+
await fs.promises.unlink(targetPath);
|
|
121
|
+
} catch (unlinkError) {
|
|
122
|
+
if (!isEnoent(unlinkError)) throw unlinkError;
|
|
123
|
+
}
|
|
124
|
+
await rename(backupPath, targetPath);
|
|
125
|
+
originalMoved = false;
|
|
376
126
|
}
|
|
377
|
-
|
|
127
|
+
throw error;
|
|
128
|
+
} finally {
|
|
129
|
+
try {
|
|
378
130
|
await fs.promises.unlink(tempPath);
|
|
379
|
-
}
|
|
380
|
-
throw err;
|
|
131
|
+
} catch {}
|
|
381
132
|
}
|
|
382
133
|
}
|
|
383
134
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
export async function runUpdateCommand(opts: { force: boolean; check: boolean }): Promise<void> {
|
|
388
|
-
console.log(chalk.dim(`Current version: ${VERSION}`));
|
|
135
|
+
export async function updateStandaloneExecutable(expectedVersion: string): Promise<void> {
|
|
136
|
+
return replaceStandaloneExecutable(expectedVersion, { targetPath: await realpath(process.execPath) });
|
|
137
|
+
}
|
|
389
138
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
139
|
+
function defaultUpdateCommandDependencies(): UpdateCommandDependencies {
|
|
140
|
+
return {
|
|
141
|
+
currentVersion: VERSION,
|
|
142
|
+
getLatestRelease,
|
|
143
|
+
resolveRecommendation: () => resolveInstallChannel(defaultInstallChannelDependencies(VERSION)),
|
|
144
|
+
updateStandalone: updateStandaloneExecutable,
|
|
145
|
+
stdout: value => console.log(value),
|
|
146
|
+
stderr: value => console.error(value),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
398
149
|
|
|
399
|
-
|
|
150
|
+
/** Run the shared policy for both `update` and `self-update`. */
|
|
151
|
+
export async function runUpdateCommand(
|
|
152
|
+
opts: { force: boolean; check: boolean },
|
|
153
|
+
deps: UpdateCommandDependencies = defaultUpdateCommandDependencies(),
|
|
154
|
+
): Promise<number> {
|
|
155
|
+
deps.stdout(chalk.dim(`Current version: ${deps.currentVersion}`));
|
|
400
156
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
157
|
+
let recommendation: UpdateRecommendation;
|
|
158
|
+
try {
|
|
159
|
+
recommendation = await deps.resolveRecommendation();
|
|
160
|
+
} catch (error) {
|
|
161
|
+
deps.stderr(chalk.red(`Update blocked: installation provenance check failed: ${error}`));
|
|
162
|
+
return 1;
|
|
163
|
+
}
|
|
164
|
+
deps.stdout(chalk.dim(`Detected channel: ${recommendation.channel}`));
|
|
165
|
+
if (recommendation.action === "blocked") {
|
|
166
|
+
deps.stderr(chalk.red(formatUpdateRecommendation(recommendation)));
|
|
167
|
+
return 1;
|
|
404
168
|
}
|
|
405
169
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
170
|
+
let release: ReleaseInfo;
|
|
171
|
+
try {
|
|
172
|
+
release = await deps.getLatestRelease();
|
|
173
|
+
} catch (error) {
|
|
174
|
+
deps.stderr(chalk.red(`Failed to check for updates: ${error}`));
|
|
175
|
+
return 1;
|
|
410
176
|
}
|
|
411
177
|
|
|
178
|
+
const comparison = compareVersions(release.version, deps.currentVersion);
|
|
179
|
+
if (comparison > 0) deps.stdout(chalk.cyan(`New version available: ${release.version}`));
|
|
180
|
+
else if (opts.force) deps.stdout(chalk.yellow(`Forcing update recommendation for ${release.version}`));
|
|
181
|
+
else deps.stdout(chalk.green("✓ Already up to date"));
|
|
182
|
+
|
|
412
183
|
if (opts.check) {
|
|
413
|
-
|
|
414
|
-
return;
|
|
184
|
+
deps.stdout(formatUpdateRecommendation(recommendation));
|
|
185
|
+
return 0;
|
|
415
186
|
}
|
|
416
187
|
|
|
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
|
-
}
|
|
188
|
+
if (recommendation.action !== "self-update") {
|
|
189
|
+
deps.stdout(formatUpdateRecommendation(recommendation));
|
|
190
|
+
return 0;
|
|
191
|
+
}
|
|
192
|
+
if (comparison <= 0 && !opts.force) return 0;
|
|
437
193
|
|
|
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);
|
|
194
|
+
try {
|
|
195
|
+
await deps.updateStandalone(release.version);
|
|
196
|
+
deps.stdout(chalk.green(`✓ Updated to ${release.version}`));
|
|
197
|
+
deps.stdout(chalk.dim(`Restart ${APP_NAME} to use the new version`));
|
|
198
|
+
return 0;
|
|
199
|
+
} catch (error) {
|
|
200
|
+
deps.stderr(chalk.red(`Update failed: ${error}`));
|
|
201
|
+
return 1;
|
|
452
202
|
}
|
|
453
203
|
}
|
|
454
204
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
205
|
+
export function getBrewUpgradeCommand(): string {
|
|
206
|
+
return "brew upgrade --cask f5-sales-demo/tap/xcsh";
|
|
207
|
+
}
|
|
208
|
+
|
|
458
209
|
export function printUpdateHelp(): void {
|
|
459
|
-
console.log(`${chalk.bold(`${APP_NAME} update`)} -
|
|
210
|
+
console.log(`${chalk.bold(`${APP_NAME} update`)} - Check the executable and follow its installation channel
|
|
460
211
|
|
|
461
212
|
${chalk.bold("Usage:")}
|
|
462
213
|
${APP_NAME} update [--check | --force]
|
|
463
214
|
${APP_NAME} update -f <manifest> [resource options]
|
|
464
215
|
|
|
465
216
|
${chalk.bold("Executable options:")}
|
|
466
|
-
-c, --check
|
|
467
|
-
--force
|
|
217
|
+
-c, --check Report the detected channel without changing files
|
|
218
|
+
--force Show or perform the channel-owned action even when current
|
|
468
219
|
|
|
469
|
-
|
|
470
|
-
|
|
220
|
+
Only an official standalone install with a valid adjacent xcsh-install.json
|
|
221
|
+
receipt can replace itself. Homebrew, apt, npm, Bun, Windows installer, and MDM
|
|
222
|
+
installs print their exact owner-managed instruction and perform no update.
|
|
223
|
+
Unknown or conflicting provenance is blocked without changing the installation.
|
|
471
224
|
|
|
472
225
|
Short -f means --filename for xcsh update. Use xcsh self-update -f for the
|
|
473
226
|
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
227
|
`);
|
|
487
228
|
}
|