@f5-sales-demo/xcsh 22.2.0 → 22.2.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,10 @@
42
42
 
43
43
  ### Changed
44
44
 
45
+ - Made standalone executable and schema-v2 receipt updates transactional, canonicalized receipt paths,
46
+ removed unused adjacent native payloads from standalone installs, and added published installer
47
+ qualification on Linux, macOS, and Windows ([#4510](https://github.com/f5-sales-demo/xcsh/issues/4510)).
48
+
45
49
  - 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
50
 
47
51
  - 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.2.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.2.1",
71
+ "@f5-sales-demo/pi-ai": "22.2.1",
72
+ "@f5-sales-demo/pi-natives": "22.2.1",
73
+ "@f5-sales-demo/pi-resource-management": "22.2.1",
74
+ "@f5-sales-demo/pi-tui": "22.2.1",
75
+ "@f5-sales-demo/pi-utils": "22.2.1",
76
+ "@f5-sales-demo/xcsh-stats": "22.2.1",
77
77
  "@mozilla/readability": "^0.6",
78
78
  "@resvg/resvg-wasm": "2.6.2",
79
79
  "@sinclair/typebox": "0.34.52",
@@ -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,12 +105,15 @@ 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;
@@ -233,7 +259,7 @@ export async function resolveInstallChannel(deps: InstallChannelDependencies): P
233
259
  if (receiptIsValid(parsed, deps, resolvedExecPath)) {
234
260
  signals.push({
235
261
  channel: parsed.channel,
236
- evidence: `valid schema-v1 ${parsed.channel} receipt at ${receiptPath}`,
262
+ evidence: `valid schema-v2 ${parsed.channel} receipt at ${receiptPath}`,
237
263
  });
238
264
  } else {
239
265
  diagnostics.push(`invalid or stale install receipt at ${receiptPath}`);