@f5-sales-demo/xcsh 22.2.0 → 22.3.1

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,13 @@
42
42
 
43
43
  ### Changed
44
44
 
45
+ - Allowed explicit `xcsh plugin setup <plugin> --force` runs to refresh dynamic setup plans even
46
+ when the current integration receipt is already ready ([#4516](https://github.com/f5-sales-demo/xcsh/issues/4516)).
47
+
48
+ - Made standalone executable and schema-v2 receipt updates transactional, canonicalized receipt paths,
49
+ removed unused adjacent native payloads from standalone installs, and added published installer
50
+ qualification on Linux, macOS, and Windows ([#4510](https://github.com/f5-sales-demo/xcsh/issues/4510)).
51
+
45
52
  - 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
53
 
47
54
  - Added `power.sleepPrevention` with an `idle` default and cumulative `off`, `idle`, `display`, and
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "22.2.0",
4
+ "version": "22.3.1",
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.2.0",
71
- "@f5-sales-demo/pi-ai": "22.2.0",
72
- "@f5-sales-demo/pi-natives": "22.2.0",
73
- "@f5-sales-demo/pi-resource-management": "22.2.0",
74
- "@f5-sales-demo/pi-tui": "22.2.0",
75
- "@f5-sales-demo/pi-utils": "22.2.0",
76
- "@f5-sales-demo/xcsh-stats": "22.2.0",
70
+ "@f5-sales-demo/pi-agent-core": "22.3.1",
71
+ "@f5-sales-demo/pi-ai": "22.3.1",
72
+ "@f5-sales-demo/pi-natives": "22.3.1",
73
+ "@f5-sales-demo/pi-resource-management": "22.3.1",
74
+ "@f5-sales-demo/pi-tui": "22.3.1",
75
+ "@f5-sales-demo/pi-utils": "22.3.1",
76
+ "@f5-sales-demo/xcsh-stats": "22.3.1",
77
77
  "@mozilla/readability": "^0.6",
78
78
  "@resvg/resvg-wasm": "2.6.2",
79
79
  "@sinclair/typebox": "0.34.52",
@@ -28,7 +28,7 @@ import {
28
28
  executeInstallAuthorizedSetup,
29
29
  executeReviewedSetup,
30
30
  } from "../integrations/setup";
31
- import type { IntegrationHandle } from "../integrations/types";
31
+ import type { IntegrationHandle, IntegrationState } from "../integrations/types";
32
32
  import { theme } from "../modes/theme/theme";
33
33
  import { personProfileService } from "../person-profile/service";
34
34
  import { ContextError, ContextService, type XCSHContext } from "../services/xcsh-context";
@@ -336,11 +336,18 @@ async function handleIntegrationStatus(args: string[], flags: { json?: boolean;
336
336
  });
337
337
  }
338
338
 
339
- export async function reviewAndExecuteIntegrationSetup(handle: IntegrationHandle<unknown>) {
339
+ export function shouldSkipIntegrationSetup(state: IntegrationState, force = false): boolean {
340
+ return state === "ready" && !force;
341
+ }
342
+
343
+ export async function reviewAndExecuteIntegrationSetup(
344
+ handle: IntegrationHandle<unknown>,
345
+ options: { force?: boolean } = {},
346
+ ) {
340
347
  const plan = handle.setupPlan;
341
348
  if (!plan) throw new Error(`Integration ${handle.id} does not declare setup`);
342
349
  const current = await handle.get();
343
- if (current.state === "ready") return current;
350
+ if (shouldSkipIntegrationSetup(current.state, options.force)) return current;
344
351
  if (plan.guidedAction?.kind === "context_wizard")
345
352
  throw new Error(
346
353
  `Plugin ${handle.plugin ?? handle.id} setup requires interactive xcsh; run /plugin setup ${handle.plugin ?? handle.id} in the xcsh TUI.`,
@@ -358,19 +365,22 @@ export async function reviewAndExecuteIntegrationSetup(handle: IntegrationHandle
358
365
  return result;
359
366
  }
360
367
 
361
- async function handleIntegrationSetup(args: string[], flags: { json?: boolean; context?: string }): Promise<void> {
368
+ async function handleIntegrationSetup(
369
+ args: string[],
370
+ flags: { json?: boolean; context?: string; force?: boolean },
371
+ ): Promise<void> {
362
372
  if (flags.json) throw new CliUsageError("plugin setup is human-only and does not accept --json");
363
- if (args.length !== 1) throw new Error(`Usage: ${APP_NAME} plugin setup <plugin> [--context <name>]`);
373
+ if (args.length !== 1) throw new Error(`Usage: ${APP_NAME} plugin setup <plugin> [--context <name>] [--force]`);
364
374
  if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("Plugin setup requires an interactive terminal");
365
375
  const handles = await loadIntegrationHandles();
366
376
  await withPluginContext(flags.context, async () => {
367
377
  const handle = selectSetupIntegration(handles, args[0]);
368
378
  const current = await handle.get();
369
- if (current.state === "ready") {
379
+ if (shouldSkipIntegrationSetup(current.state, flags.force)) {
370
380
  process.stdout.write(`${handle.plugin ?? handle.id}: ready (setup is not required)\n`);
371
381
  return;
372
382
  }
373
- const result = await reviewAndExecuteIntegrationSetup(handle);
383
+ const result = await reviewAndExecuteIntegrationSetup(handle, { force: flags.force });
374
384
  process.stdout.write(
375
385
  `${handle.plugin ?? handle.id}: ${result.state}${result.reason ? ` (${result.reason})` : ""}\n`,
376
386
  );
@@ -1,10 +1,12 @@
1
1
  /** Context-aware executable update policy and standalone replacement. */
2
2
  import * as fs from "node:fs";
3
3
  import { realpath } from "node:fs/promises";
4
+ import path from "node:path";
4
5
  import { pipeline } from "node:stream/promises";
5
6
  import { APP_NAME, isEnoent, VERSION } from "@f5-sales-demo/pi-utils";
6
7
  import chalk from "chalk";
7
8
  import {
9
+ createInstallReceipt,
8
10
  defaultInstallChannelDependencies,
9
11
  formatUpdateRecommendation,
10
12
  resolveInstallChannel,
@@ -70,23 +72,49 @@ export interface StandaloneReplacementOptions {
70
72
  arch?: NodeJS.Architecture;
71
73
  fetchImpl?(input: string | URL | Request, init?: RequestInit): Promise<Response>;
72
74
  rename?(source: string, destination: string): Promise<void>;
75
+ readFile?(file: string): Promise<string>;
76
+ writeFile?(file: string, contents: string): Promise<void>;
77
+ unlink?(file: string): Promise<void>;
73
78
  validate?(executable: string, expectedVersion: string): Promise<void>;
74
79
  }
75
80
 
76
- /** Atomically replace one proven standalone executable, restoring it on every failure. */
81
+ async function ignoreMissing(unlink: (file: string) => Promise<void>, file: string): Promise<void> {
82
+ try {
83
+ await unlink(file);
84
+ } catch (error) {
85
+ if (!isEnoent(error)) throw error;
86
+ }
87
+ }
88
+
89
+ /** Replace a proven standalone executable and receipt as one rollback-safe transaction. */
77
90
  export async function replaceStandaloneExecutable(
78
91
  expectedVersion: string,
79
92
  options: StandaloneReplacementOptions,
80
93
  ): Promise<void> {
81
94
  const targetPath = options.targetPath;
82
- const binaryName = getBinaryName(options.platform, options.arch);
95
+ const platform = options.platform ?? process.platform;
96
+ const arch = options.arch ?? process.arch;
97
+ const binaryName = getBinaryName(platform, arch);
83
98
  const url = `https://github.com/${REPO}/releases/download/v${expectedVersion}/${binaryName}`;
84
99
  const tempPath = `${targetPath}.new-${process.pid}`;
85
- const backupPath = `${targetPath}.bak`;
100
+ const receiptPath = path.join(path.dirname(targetPath), "xcsh-install.json");
101
+ const tempReceiptPath = `${receiptPath}.new-${process.pid}`;
102
+ const backupPath = `${targetPath}.bak-${process.pid}`;
103
+ const backupReceiptPath = `${receiptPath}.bak-${process.pid}`;
86
104
  const fetchImpl = options.fetchImpl ?? fetch;
87
105
  const rename = options.rename ?? fs.promises.rename;
106
+ const readFile = options.readFile ?? (file => fs.promises.readFile(file, "utf8"));
107
+ const writeFile =
108
+ options.writeFile ??
109
+ (async (file, contents) => fs.promises.writeFile(file, contents, { flag: "wx", mode: 0o644 }));
110
+ const unlink = options.unlink ?? fs.promises.unlink;
88
111
  const validate = options.validate ?? validateExecutableVersion;
89
- let originalMoved = false;
112
+ const receiptContents = `${JSON.stringify(createInstallReceipt("standalone", expectedVersion, targetPath, platform, arch))}\n`;
113
+ let originalBinaryMoved = false;
114
+ let originalReceiptMoved = false;
115
+ let replacementBinaryInstalled = false;
116
+ let replacementReceiptInstalled = false;
117
+ let committed = false;
90
118
 
91
119
  try {
92
120
  const response = await fetchImpl(url, { redirect: "follow" });
@@ -94,33 +122,69 @@ export async function replaceStandaloneExecutable(
94
122
  await pipeline(response.body, fs.createWriteStream(tempPath, { mode: 0o755, flags: "wx" }));
95
123
  await fs.promises.chmod(tempPath, 0o755);
96
124
  await validate(tempPath, expectedVersion);
125
+ await writeFile(tempReceiptPath, receiptContents);
126
+ if ((await readFile(tempReceiptPath)) !== receiptContents)
127
+ throw new Error("Staged install receipt validation failed");
97
128
 
98
- try {
99
- await fs.promises.unlink(backupPath);
100
- } catch (error) {
101
- if (!isEnoent(error)) throw error;
102
- }
103
129
  await rename(targetPath, backupPath);
104
- originalMoved = true;
130
+ originalBinaryMoved = true;
131
+ await rename(receiptPath, backupReceiptPath);
132
+ originalReceiptMoved = true;
105
133
  await rename(tempPath, targetPath);
134
+ replacementBinaryInstalled = true;
135
+ await rename(tempReceiptPath, receiptPath);
136
+ replacementReceiptInstalled = true;
106
137
  await validate(targetPath, expectedVersion);
107
- await fs.promises.unlink(backupPath);
108
- originalMoved = false;
138
+ if ((await readFile(receiptPath)) !== receiptContents) throw new Error("Installed receipt validation failed");
139
+ committed = true;
140
+ await ignoreMissing(unlink, backupPath).catch(() => {});
141
+ originalBinaryMoved = false;
142
+ await ignoreMissing(unlink, backupReceiptPath).catch(() => {});
143
+ originalReceiptMoved = false;
109
144
  } catch (error) {
110
- if (originalMoved) {
145
+ const rollbackErrors: unknown[] = [];
146
+ if (replacementReceiptInstalled) {
147
+ try {
148
+ await ignoreMissing(unlink, receiptPath);
149
+ replacementReceiptInstalled = false;
150
+ } catch (rollbackError) {
151
+ rollbackErrors.push(rollbackError);
152
+ }
153
+ }
154
+ if (originalReceiptMoved) {
155
+ try {
156
+ await rename(backupReceiptPath, receiptPath);
157
+ originalReceiptMoved = false;
158
+ } catch (rollbackError) {
159
+ rollbackErrors.push(rollbackError);
160
+ }
161
+ }
162
+ if (replacementBinaryInstalled) {
111
163
  try {
112
- await fs.promises.unlink(targetPath);
113
- } catch (unlinkError) {
114
- if (!isEnoent(unlinkError)) throw unlinkError;
164
+ await ignoreMissing(unlink, targetPath);
165
+ replacementBinaryInstalled = false;
166
+ } catch (rollbackError) {
167
+ rollbackErrors.push(rollbackError);
115
168
  }
116
- await rename(backupPath, targetPath);
117
- originalMoved = false;
118
169
  }
170
+ if (originalBinaryMoved) {
171
+ try {
172
+ await rename(backupPath, targetPath);
173
+ originalBinaryMoved = false;
174
+ } catch (rollbackError) {
175
+ rollbackErrors.push(rollbackError);
176
+ }
177
+ }
178
+ if (rollbackErrors.length > 0)
179
+ throw new AggregateError([error, ...rollbackErrors], "Self-update rollback failed");
119
180
  throw error;
120
181
  } finally {
121
- try {
122
- await fs.promises.unlink(tempPath);
123
- } catch {}
182
+ await ignoreMissing(unlink, tempPath).catch(() => {});
183
+ await ignoreMissing(unlink, tempReceiptPath).catch(() => {});
184
+ if (committed) {
185
+ await ignoreMissing(unlink, backupPath).catch(() => {});
186
+ await ignoreMissing(unlink, backupReceiptPath).catch(() => {});
187
+ }
124
188
  }
125
189
  }
126
190
 
@@ -37,8 +37,10 @@ export interface InstallChannelDependencies {
37
37
  run(command: string, args: string[]): Promise<CommandResult>;
38
38
  }
39
39
 
40
- interface InstallReceipt {
41
- schemaVersion: 1;
40
+ export const INSTALL_RECEIPT_SCHEMA_VERSION = 2;
41
+
42
+ export interface InstallReceipt {
43
+ schemaVersion: typeof INSTALL_RECEIPT_SCHEMA_VERSION;
42
44
  channel: "standalone" | "windows-installer";
43
45
  version: string;
44
46
  executablePath: string;
@@ -46,6 +48,8 @@ interface InstallReceipt {
46
48
  arch: string;
47
49
  }
48
50
 
51
+ const INSTALL_RECEIPT_KEYS = ["arch", "channel", "executablePath", "platform", "schemaVersion", "version"];
52
+
49
53
  function platformPath(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 {
50
54
  return platform === "win32" ? path.win32 : path.posix;
51
55
  }
@@ -55,6 +59,25 @@ function comparablePath(value: string, platform: NodeJS.Platform): string {
55
59
  return platform === "win32" ? normalized.toLowerCase() : normalized;
56
60
  }
57
61
 
62
+ export function createInstallReceipt(
63
+ channel: InstallReceipt["channel"],
64
+ version: string,
65
+ executablePath: string,
66
+ platform: NodeJS.Platform,
67
+ arch: string,
68
+ ): InstallReceipt {
69
+ const pathApi = platformPath(platform);
70
+ if (!pathApi.isAbsolute(executablePath)) throw new Error("Install receipt executable path must be absolute");
71
+ return {
72
+ schemaVersion: INSTALL_RECEIPT_SCHEMA_VERSION,
73
+ channel,
74
+ version,
75
+ executablePath: pathApi.normalize(executablePath),
76
+ platform,
77
+ arch,
78
+ };
79
+ }
80
+
58
81
  function releaseAsset(platform: NodeJS.Platform, arch: string): string | undefined {
59
82
  if (platform === "linux" && (arch === "x64" || arch === "arm64")) return `xcsh-linux-${arch}`;
60
83
  if (platform === "darwin" && (arch === "x64" || arch === "arm64")) return `xcsh-darwin-${arch}`;
@@ -82,24 +105,21 @@ function receiptIsValid(
82
105
  if (!value || typeof value !== "object") return false;
83
106
  const receipt = value as Partial<InstallReceipt>;
84
107
  if (
85
- receipt.schemaVersion !== 1 ||
108
+ JSON.stringify(Object.keys(receipt).sort()) !== JSON.stringify(INSTALL_RECEIPT_KEYS) ||
109
+ receipt.schemaVersion !== INSTALL_RECEIPT_SCHEMA_VERSION ||
86
110
  (receipt.channel !== "standalone" && receipt.channel !== "windows-installer") ||
87
111
  receipt.version !== deps.version ||
88
112
  receipt.platform !== deps.platform ||
89
113
  receipt.arch !== deps.arch ||
90
114
  typeof receipt.executablePath !== "string" ||
115
+ !platformPath(deps.platform).isAbsolute(receipt.executablePath) ||
116
+ platformPath(deps.platform).normalize(receipt.executablePath) !== receipt.executablePath ||
91
117
  comparablePath(receipt.executablePath, deps.platform) !== comparablePath(resolvedExecPath, deps.platform)
92
118
  ) {
93
119
  return false;
94
120
  }
95
121
  if (receipt.channel === "standalone") return deps.platform !== "win32";
96
- if (deps.platform !== "win32") return false;
97
- const localAppData = deps.env.LOCALAPPDATA;
98
- if (!localAppData) return false;
99
- return (
100
- comparablePath(resolvedExecPath, deps.platform) ===
101
- comparablePath(path.win32.join(localAppData, "xcsh", "xcsh.exe"), deps.platform)
102
- );
122
+ return deps.platform === "win32";
103
123
  }
104
124
 
105
125
  async function defaultRun(command: string, args: string[]): Promise<CommandResult> {
@@ -233,7 +253,7 @@ export async function resolveInstallChannel(deps: InstallChannelDependencies): P
233
253
  if (receiptIsValid(parsed, deps, resolvedExecPath)) {
234
254
  signals.push({
235
255
  channel: parsed.channel,
236
- evidence: `valid schema-v1 ${parsed.channel} receipt at ${receiptPath}`,
256
+ evidence: `valid schema-v2 ${parsed.channel} receipt at ${receiptPath}`,
237
257
  });
238
258
  } else {
239
259
  diagnostics.push(`invalid or stale install receipt at ${receiptPath}`);