@f5-sales-demo/xcsh 22.0.1 → 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 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.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.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.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",
@@ -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 * 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
- }
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(`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
- };
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 na = pa[i] || 0;
194
- const nb = pb[i] || 0;
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
- * 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;
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
- * 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
- ),
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
- * 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)."));
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
- 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`;
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
- console.log(chalk.dim(`Downloading ${binaryName}…`));
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 (err) {
365
- if (!isEnoent(err)) throw err;
108
+ } catch (error) {
109
+ if (!isEnoent(error)) throw error;
366
110
  }
367
- await fs.promises.rename(targetPath, backupPath);
368
- await fs.promises.rename(tempPath, targetPath);
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
- 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);
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
- if (fs.existsSync(tempPath)) {
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
- * 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}`));
135
+ export async function updateStandaloneExecutable(expectedVersion: string): Promise<void> {
136
+ return replaceStandaloneExecutable(expectedVersion, { targetPath: await realpath(process.execPath) });
137
+ }
389
138
 
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
- }
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
- const comparison = compareVersions(release.version, VERSION);
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
- if (comparison <= 0 && !opts.force) {
402
- console.log(chalk.green(`${theme.status.success} Already up to date`));
403
- return;
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
- 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}`));
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
- // Just check, don't install
414
- return;
184
+ deps.stdout(formatUpdateRecommendation(recommendation));
185
+ return 0;
415
186
  }
416
187
 
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
- }
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
- // #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);
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
- * Print update command help.
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`)} - Update the executable or existing resources from manifests
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 Check for executable updates without installing
467
- --force Force reinstall even if up to date
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
- ${chalk.bold("Resource input:")}
470
- -f, --filename <manifest> Update resources from a manifest
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
  }