@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 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.0.1",
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.0.1",
71
- "@f5-sales-demo/pi-ai": "22.0.1",
72
- "@f5-sales-demo/pi-natives": "22.0.1",
73
- "@f5-sales-demo/pi-resource-management": "22.0.1",
74
- "@f5-sales-demo/pi-tui": "22.0.1",
75
- "@f5-sales-demo/pi-utils": "22.0.1",
76
- "@f5-sales-demo/xcsh-stats": "22.0.1",
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",
@@ -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 * as path from "node:path";
3
+ import { realpath } from "node:fs/promises";
10
4
  import { pipeline } from "node:stream/promises";
11
- import { $which, APP_NAME, isEnoent, VERSION } from "@f5-sales-demo/pi-utils";
12
- import { $ } from "bun";
5
+ import { APP_NAME, isEnoent, VERSION } from "@f5-sales-demo/pi-utils";
13
6
  import chalk from "chalk";
14
- import { theme } from "../modes/theme/theme";
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
- * Parse update subcommand arguments.
26
- * Returns undefined if not a self-update command.
27
- */
28
- export function parseUpdateArgs(args: string[]): { force: boolean; check: boolean } | undefined {
29
- if (args.length === 0 || args[0] !== "self-update") {
30
- return undefined;
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(`https://registry.npmjs.org/${PACKAGE}/latest`);
168
- if (!response.ok) {
169
- throw new Error(`Failed to fetch release info: ${response.statusText}`);
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 na = pa[i] || 0;
194
- const nb = pb[i] || 0;
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
- * Get the appropriate binary name for this platform.
202
- */
203
- function getBinaryName(): string {
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
- * Run the resolved xcsh binary and check if it reports the expected version.
249
- */
250
- async function verifyInstalledVersion(
251
- expectedVersion: string,
252
- explicitPath?: string,
253
- ): Promise<{ ok: boolean; actual?: string; path?: string }> {
254
- const ompPath = explicitPath ?? resolveOmpPath();
255
- if (!ompPath) return { ok: false };
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
- * Handle brew-installed xcsh.
322
- *
323
- * For corporate environments, brew-managed software must not be bypassed.
324
- * Prints instructions instead of running brew upgrade automatically.
325
- */
326
- function updateViaBrew(targetPath: string, expectedVersion: string): void {
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
- export function getBrewUpgradeCommand(): string {
338
- return `brew upgrade --cask ${APP_NAME}`;
339
- }
340
-
341
- /**
342
- * Download a release binary to a target path, replacing an existing file.
343
- */
344
- async function updateViaBinaryAt(targetPath: string, expectedVersion: string): Promise<void> {
345
- const binaryName = getBinaryName();
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
- console.log(chalk.dim(`Downloading ${binaryName}…`));
352
-
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);
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 (err) {
365
- if (!isEnoent(err)) throw err;
100
+ } catch (error) {
101
+ if (!isEnoent(error)) throw error;
366
102
  }
367
- await fs.promises.rename(targetPath, backupPath);
368
- await fs.promises.rename(tempPath, targetPath);
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
- await printVerification(expectedVersion, targetPath);
372
- console.log(chalk.dim(`Restart ${APP_NAME} to use the new version`));
373
- } catch (err) {
374
- if (fs.existsSync(backupPath) && !fs.existsSync(targetPath)) {
375
- await fs.promises.rename(backupPath, targetPath);
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
- if (fs.existsSync(tempPath)) {
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
- * Run the update command.
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
- // Check for updates
391
- let release: ReleaseInfo;
392
- try {
393
- release = await getLatestRelease();
394
- } catch (err) {
395
- console.error(chalk.red(`Failed to check for updates: ${err}`));
396
- process.exit(1);
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
- const comparison = compareVersions(release.version, VERSION);
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
- if (comparison <= 0 && !opts.force) {
402
- console.log(chalk.green(`${theme.status.success} Already up to date`));
403
- return;
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
- if (comparison > 0) {
407
- console.log(chalk.cyan(`New version available: ${release.version}`));
408
- } else {
409
- console.log(chalk.yellow(`Forcing reinstall of ${release.version}`));
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
- // Just check, don't install
414
- return;
176
+ deps.stdout(formatUpdateRecommendation(recommendation));
177
+ return 0;
415
178
  }
416
179
 
417
- // Choose update method based on the prioritized xcsh binary in PATH
418
- try {
419
- const target = await resolveUpdateTarget();
420
- console.log(chalk.dim(`Install method: ${target.method}`));
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
- // #1874 Task 7: the binary just changed under a possibly-running OLD manager.
439
- // Proactively recycle so the new version takes effect now instead of on the
440
- // next Chrome launch — refresh the native-host wrapper + step the old manager
441
- // down (its successor re-adopts live workers, zero-downtime). The just-updated
442
- // `xcsh` on PATH is the new version. Best-effort, non-blocking; passive
443
- // supersede covers it if this is skipped.
444
- try {
445
- Bun.spawn(["xcsh", "chrome", "recycle"], { stdout: "ignore", stderr: "ignore" });
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
- * Print update command help.
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
  }