impel-cli 0.18.2 → 0.18.4

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/README.md CHANGED
@@ -255,6 +255,11 @@ compatibility for one release, but no command can enable native routing.
255
255
  ### Windows
256
256
 
257
257
  - Missing official Claude Code and Codex CLIs can be installed during setup.
258
+ - Claude Desktop is downloaded from Anthropic as the exact x64 or arm64 MSIX
259
+ pinned by this CLI. Its SHA-256, Authenticode signature, package identity,
260
+ version, architecture, and required layout are checked before installation.
261
+ - ChatGPT/Codex uses the Microsoft Store package: a missing package is installed
262
+ during either setup or update, while an existing package is upgraded.
258
263
  - Signed vendor desktop executables remain unchanged.
259
264
  - Tenant profiles and Start entries are isolated per tenant.
260
265
  - Shared package-manager/vendor preparation runs once per product.
@@ -324,6 +329,14 @@ run a real package manager, or modify native personal profiles.
324
329
  CI runs on Ubuntu, macOS, and Windows with supported Node versions. See
325
330
  [RELEASING.md](RELEASING.md) for the npm release process.
326
331
 
332
+ The separate Windows vendor contract runs only on a disposable GitHub-hosted
333
+ Windows VM. It installs the exact newer Claude fixture that preceded the
334
+ current compatibility pin, invokes the production downloader and installer to
335
+ downgrade to the exact pinned MSIX, verifies the registered AppX identity,
336
+ version, architecture, executable, and idempotent second run, then removes the
337
+ package. Docker/Wine remains useful for Windows path and process semantics, but
338
+ cannot validate AppX deployment, packaged services, trust, or Store behavior.
339
+
327
340
  The macOS config-contract job downloads the exact ChatGPT archive declared in
328
341
  `PINNED_VENDOR_APPS`, verifies its checksum, signature, app version, and
329
342
  embedded Codex version, then validates a deterministic generated profile with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.18.2",
3
+ "version": "0.18.4",
4
4
  "description": "Prepare isolated Claude and Codex workspaces for every accessible Impel tenant",
5
5
  "type": "module",
6
6
  "bin": {
package/src/apps.js CHANGED
@@ -53,6 +53,22 @@ export const PINNED_VENDOR_APPS = Object.freeze({
53
53
  claudeCodeVersion: "2.1.209",
54
54
  claudeCodeCommit: "0fe048596fd45e79e99353cbe2c3d1b1ac069568",
55
55
  bundleName: "Claude.app",
56
+ windows: Object.freeze({
57
+ packageName: "Claude",
58
+ packageVersion: "1.20186.9.0",
59
+ publisher: 'CN="Anthropic, PBC", O="Anthropic, PBC", L=San Francisco, S=California, C=US, SERIALNUMBER=4860621, OID.2.5.4.15=Private Organization, OID.1.3.6.1.4.1.311.60.2.1.2=Delaware, OID.1.3.6.1.4.1.311.60.2.1.3=US',
60
+ executable: "app\\Claude.exe",
61
+ downloads: Object.freeze({
62
+ arm64: Object.freeze({
63
+ url: "https://downloads.claude.ai/releases/win32/arm64/1.20186.9/Claude-69f150a4c9316d5c8cd7b9f130ed583d15c0383e.msix",
64
+ sha256: "9a0591a0d9e298dea92d5547ee259f35e818ce0b1982512ad1550f5ed2744c39",
65
+ }),
66
+ x64: Object.freeze({
67
+ url: "https://downloads.claude.ai/releases/win32/x64/1.20186.9/Claude-69f150a4c9316d5c8cd7b9f130ed583d15c0383e.msix",
68
+ sha256: "55a0b8f8c3c8d46cd64a03d3f42818da28f312cd4be904265cd61baee7ea68a5",
69
+ }),
70
+ }),
71
+ }),
56
72
  downloads: Object.freeze({
57
73
  arm64: Object.freeze({
58
74
  url: "https://downloads.claude.ai/releases/darwin/universal/1.20186.9/Claude-69f150a4c9316d5c8cd7b9f130ed583d15c0383e.zip",
@@ -402,7 +402,7 @@ export async function reconcileWindowsTenantApps({
402
402
  fail(target, `${label} vendor ${mode === "update" ? "update" : "installation"} requires confirmation`);
403
403
  continue;
404
404
  }
405
- const vendor = ensure({ update: mode === "update" }, { environment });
405
+ const vendor = await ensure({ update: mode === "update" }, { environment, homeDir });
406
406
  binary = vendor.binary;
407
407
  if (!binary) {
408
408
  fail(target, `${label} vendor installation failed (${windowsProcessFailure(vendor.result)})`);
@@ -741,7 +741,10 @@ export async function cmdWindowsApps(argv, overrides = {}) {
741
741
  || (action === "open" && !binary)
742
742
  );
743
743
  if (shouldEnsureVendor) {
744
- const vendor = ensure({ update: action === "update" }, { environment: io.environment });
744
+ const vendor = await ensure(
745
+ { update: action === "update" },
746
+ { environment: io.environment, homeDir: io.homeDir },
747
+ );
745
748
  binary = vendor.binary;
746
749
  console.log(`${target}: vendor app ${vendor.action}`);
747
750
  if (!binary) {
@@ -91,13 +91,18 @@ export async function cmdConverge(argv = [], overrides = {}) {
91
91
  // answer (not unref'd) so the auto-decline reliably fires even when the
92
92
  // prompt is the only thing keeping the event loop alive.
93
93
  const allowed = confirmed(await new Promise((resolve) => {
94
- const timer = setTimeout(() => resolve(""), io.vendorPromptTimeoutMs ?? 60_000);
94
+ const controller = new AbortController();
95
+ const timer = setTimeout(() => {
96
+ controller.abort();
97
+ resolve("");
98
+ }, io.vendorPromptTimeoutMs ?? 60_000);
95
99
  const settle = (answer) => {
96
100
  clearTimeout(timer);
97
101
  resolve(answer);
98
102
  };
99
103
  Promise.resolve(io.promptText(
100
104
  `Allow the verified ${label} vendor ${context.mode === "update" ? "update" : "installation"}? [y/N] (auto-N in 60s) `,
105
+ { signal: controller.signal },
101
106
  )).then(settle, () => settle(""));
102
107
  }));
103
108
  vendorAppDecisions.set(target, allowed);
@@ -272,7 +277,14 @@ export async function cmdConverge(argv = [], overrides = {}) {
272
277
  || product === target
273
278
  || (target === "codex" && product === "chatgpt")
274
279
  ));
275
- return vendorAppTargetReady(retryReport, target);
280
+ const installed = vendorAppTargetReady(retryReport, target);
281
+ return {
282
+ installed,
283
+ error: installed
284
+ ? null
285
+ : retryReport?.tenants?.[0]?.errors?.join("; ")
286
+ || `The ${target} vendor app did not reach a verified state.`,
287
+ };
276
288
  },
277
289
  },
278
290
  }, overrides.recoveryOverrides || {});
@@ -453,7 +453,14 @@ export async function cmdSetup(argv, overrides = {}) {
453
453
  || product === target
454
454
  || (target === "codex" && product === "chatgpt")
455
455
  ));
456
- return vendorAppTargetReady(retryReport, target);
456
+ const installed = vendorAppTargetReady(retryReport, target);
457
+ return {
458
+ installed,
459
+ error: installed
460
+ ? null
461
+ : retryReport?.tenants?.[0]?.errors?.join("; ")
462
+ || `The ${target} vendor app did not reach a verified state.`,
463
+ };
457
464
  },
458
465
  },
459
466
  }, overrides.recoveryOverrides || {});
@@ -500,10 +500,22 @@ async function installVendorApp(input, context) {
500
500
  if (typeof context.installVendorApp !== "function") {
501
501
  return result("failed", "Vendor app installation is unavailable in this recovery context.");
502
502
  }
503
- const installed = await context.installVendorApp(input.target);
504
- return installed === false
505
- ? result("failed", `The ${input.target} vendor app did not install cleanly.`)
506
- : result("succeeded", `The ${input.target} vendor app installation completed.`);
503
+ const outcome = await context.installVendorApp(input.target);
504
+ const installed = typeof outcome === "object" && outcome !== null
505
+ ? outcome.installed !== false
506
+ : outcome !== false;
507
+ if (installed) {
508
+ return result("succeeded", `The ${input.target} vendor app installation completed.`);
509
+ }
510
+ const detail = typeof outcome === "object" && outcome?.error
511
+ ? redactInstallRecoveryText(outcome.error).slice(0, 1_000)
512
+ : null;
513
+ return result(
514
+ "failed",
515
+ detail
516
+ ? `The ${input.target} vendor app did not install cleanly: ${detail}`
517
+ : `The ${input.target} vendor app did not install cleanly.`,
518
+ );
507
519
  }
508
520
 
509
521
  function repairUserPath(input, context, io) {
package/src/prompt.js CHANGED
@@ -40,12 +40,32 @@ export function promptSecret(question) {
40
40
  });
41
41
  }
42
42
 
43
- export function promptText(question) {
43
+ export function promptText(question, {
44
+ signal = null,
45
+ input = process.stdin,
46
+ output = process.stdout,
47
+ } = {}) {
44
48
  return new Promise((resolve) => {
45
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
46
- rl.question(question, (answer) => {
49
+ const rl = readline.createInterface({ input, output });
50
+ let settled = false;
51
+ const finish = (answer, aborted = false) => {
52
+ if (settled) return;
53
+ settled = true;
54
+ signal?.removeEventListener("abort", abort);
47
55
  rl.close();
48
- resolve(answer.trim());
56
+ // A cancelled readline prompt otherwise leaves the next status line on
57
+ // the same terminal row as its unanswered question.
58
+ if (aborted && output?.isTTY) output.write("\n");
59
+ resolve(String(answer || "").trim());
60
+ };
61
+ const abort = () => finish("", true);
62
+ if (signal?.aborted) {
63
+ abort();
64
+ return;
65
+ }
66
+ signal?.addEventListener("abort", abort, { once: true });
67
+ rl.question(question, (answer) => {
68
+ finish(answer);
49
69
  });
50
70
  });
51
71
  }
@@ -3,14 +3,18 @@ import crypto from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
+ import { Readable } from "node:stream";
7
+ import { pipeline } from "node:stream/promises";
6
8
 
9
+ import { PINNED_VENDOR_APPS } from "./apps.js";
7
10
  import { environmentValue, nativeCommandInvocation } from "./nativeProcess.js";
8
11
  import { normalizeTenantId } from "./tenants.js";
9
12
 
10
- export const WINDOWS_CLAUDE_PACKAGE = "Anthropic.Claude";
11
13
  export const WINDOWS_CHATGPT_PACKAGE = "9PLM9XGG6VKS";
12
14
  export const WINDOWS_CHATGPT_PACKAGE_NAME = "OpenAI.Codex";
13
15
  export const WINDOWS_CHATGPT_PUBLISHER_ID = "2p2nqsd0c76g0";
16
+ const WINDOWS_CLAUDE_DOWNLOAD_TIMEOUT_MS = 5 * 60 * 1000;
17
+ const WINDOWS_WINGET_UPDATE_NOT_APPLICABLE = 0x8A15002B;
14
18
 
15
19
  export function windowsClaudeUserData(environment = process.env, tenantId = null) {
16
20
  const localAppData = environmentValue(environment, "LOCALAPPDATA")
@@ -31,6 +35,16 @@ function isFile(filePath) {
31
35
  }
32
36
  }
33
37
 
38
+ // A CLI launched from PowerShell 7 inherits its PSModulePath. Passing that
39
+ // value into Windows PowerShell 5.1 makes legacy modules such as
40
+ // Microsoft.PowerShell.Security visible but unloadable. Omit the variable so
41
+ // powershell.exe reconstructs its own version-correct default module path.
42
+ function windowsPowerShellEnvironment(environment = process.env) {
43
+ return Object.fromEntries(
44
+ Object.entries(environment).filter(([key]) => key.toLowerCase() !== "psmodulepath"),
45
+ );
46
+ }
47
+
34
48
  function versionedSquirrelCandidates(root, readDirectory = fs.readdirSync) {
35
49
  try {
36
50
  return readDirectory(root, { withFileTypes: true })
@@ -45,24 +59,6 @@ function versionedSquirrelCandidates(root, readDirectory = fs.readdirSync) {
45
59
  }
46
60
  }
47
61
 
48
- function installedMsixClaude(environment, run = spawnSync) {
49
- const script = [
50
- "$package = Get-AppxPackage -Name Claude | Sort-Object Version -Descending | Select-Object -First 1",
51
- "if ($package) { Join-Path $package.InstallLocation 'app\\Claude.exe' }",
52
- ].join("; ");
53
- try {
54
- const result = run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
55
- encoding: "utf8",
56
- env: environment,
57
- stdio: ["ignore", "pipe", "ignore"],
58
- windowsHide: true,
59
- });
60
- return result?.status === 0 ? String(result.stdout || "").trim() || null : null;
61
- } catch {
62
- return null;
63
- }
64
- }
65
-
66
62
  function installedMsixChatGPT(environment, run = spawnSync) {
67
63
  const script = [
68
64
  `$package = Get-AppxPackage -Name '${WINDOWS_CHATGPT_PACKAGE_NAME}' -ErrorAction SilentlyContinue`,
@@ -76,7 +72,7 @@ function installedMsixChatGPT(environment, run = spawnSync) {
76
72
  try {
77
73
  const result = run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
78
74
  encoding: "utf8",
79
- env: environment,
75
+ env: windowsPowerShellEnvironment(environment),
80
76
  stdio: ["ignore", "pipe", "ignore"],
81
77
  windowsHide: true,
82
78
  });
@@ -108,11 +104,12 @@ export function windowsClaudeAppCandidates(environment = process.env, dependenci
108
104
  }
109
105
 
110
106
  export function findWindowsClaudeApp(environment = process.env, dependencies = {}) {
111
- const exists = dependencies.isFile || isFile;
112
- const unpackaged = windowsClaudeAppCandidates(environment, dependencies).find((candidate) => exists(candidate));
113
- if (unpackaged) return unpackaged;
114
- const msix = (dependencies.queryMsix || installedMsixClaude)(environment, dependencies.run);
115
- return msix && exists(msix) ? path.win32.normalize(msix) : null;
107
+ if (dependencies.queryMsix) {
108
+ const msix = dependencies.queryMsix(environment, dependencies.run);
109
+ const exists = dependencies.isFile || isFile;
110
+ return msix && exists(msix) ? path.win32.normalize(msix) : null;
111
+ }
112
+ return pinnedWindowsClaudeApp(environment, dependencies);
116
113
  }
117
114
 
118
115
  /** Candidate paths for an unpackaged OpenAI ChatGPT/Codex desktop install. */
@@ -166,37 +163,210 @@ function wingetInvocation(action, environment, { packageId, source = "winget", s
166
163
  );
167
164
  }
168
165
 
169
- /** Install/update Anthropic's signed per-user app without modifying its files. */
170
- export function ensureWindowsClaudeApp({ update = false } = {}, dependencies = {}) {
166
+ function windowsClaudeArchitecture(environment, fallback = process.arch) {
167
+ const detected = environmentValue(environment, "PROCESSOR_ARCHITEW6432")
168
+ || environmentValue(environment, "PROCESSOR_ARCHITECTURE")
169
+ || fallback;
170
+ if (/arm64/iu.test(detected)) return "arm64";
171
+ if (/amd64|x64/iu.test(detected)) return "x64";
172
+ return fallback;
173
+ }
174
+
175
+ function pinnedWindowsClaudeApp(environment = process.env, dependencies = {}) {
171
176
  const io = {
172
- environment: process.env,
173
- find: findWindowsClaudeApp,
174
177
  run: spawnSync,
178
+ isFile,
179
+ pin: PINNED_VENDOR_APPS.claude.windows,
180
+ architecture: windowsClaudeArchitecture(environment),
175
181
  ...dependencies,
176
182
  };
177
- const before = io.find(io.environment);
178
- if (before && !update) return { binary: before, action: "existing", result: null };
179
-
180
- const invocation = wingetInvocation(update ? "update" : "install", io.environment, {
181
- packageId: WINDOWS_CLAUDE_PACKAGE,
182
- });
183
- let result;
183
+ const expectedArchitecture = io.architecture === "arm64" ? "Arm64" : "X64";
184
+ const script = [
185
+ "$package = Get-AppxPackage -Name 'Claude' -ErrorAction SilentlyContinue",
186
+ "$package = $package | Where-Object { $_.Version.ToString() -ceq $env:IMPEL_CLAUDE_PACKAGE_VERSION -and $_.Publisher -ceq $env:IMPEL_CLAUDE_PACKAGE_PUBLISHER -and $_.Architecture.ToString() -ieq $env:IMPEL_CLAUDE_PACKAGE_ARCHITECTURE } | Select-Object -First 1",
187
+ "if ($package) { Join-Path $package.InstallLocation $env:IMPEL_CLAUDE_PACKAGE_EXECUTABLE }",
188
+ ].join("; ");
184
189
  try {
185
- result = io.run(invocation.command, invocation.args, {
186
- env: io.environment,
187
- stdio: "inherit",
188
- windowsVerbatimArguments: invocation.windowsVerbatimArguments,
190
+ const result = io.run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
191
+ encoding: "utf8",
192
+ env: {
193
+ ...windowsPowerShellEnvironment(environment),
194
+ IMPEL_CLAUDE_PACKAGE_VERSION: io.pin.packageVersion,
195
+ IMPEL_CLAUDE_PACKAGE_PUBLISHER: io.pin.publisher,
196
+ IMPEL_CLAUDE_PACKAGE_ARCHITECTURE: expectedArchitecture,
197
+ IMPEL_CLAUDE_PACKAGE_EXECUTABLE: io.pin.executable,
198
+ },
199
+ stdio: ["ignore", "pipe", "ignore"],
200
+ windowsHide: true,
189
201
  });
202
+ const binary = result?.status === 0 ? String(result.stdout || "").trim() : "";
203
+ return binary && io.isFile(binary) ? path.win32.normalize(binary) : null;
204
+ } catch {
205
+ return null;
206
+ }
207
+ }
208
+
209
+ async function downloadWindowsClaudeMsix(url, destination, fetchImpl, timeoutMs) {
210
+ const controller = new AbortController();
211
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
212
+ try {
213
+ const response = await fetchImpl(url, { signal: controller.signal, redirect: "follow" });
214
+ if (!response?.ok || !response.body) {
215
+ throw new Error(`Claude MSIX download failed (HTTP ${response?.status ?? "error"})`);
216
+ }
217
+ await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(destination, { flags: "wx" }));
190
218
  } catch (error) {
191
- result = { status: null, error };
219
+ if (error?.name === "AbortError") {
220
+ throw new Error(`Claude MSIX download timed out after ${timeoutMs}ms`);
221
+ }
222
+ throw error;
223
+ } finally {
224
+ clearTimeout(timeout);
192
225
  }
193
- const binary = io.find(io.environment) || before;
194
- const succeeded = result?.status === 0 && !result?.error;
195
- return {
196
- binary,
197
- action: succeeded ? (update ? "updated" : "installed") : (before ? "existing" : "install-failed"),
198
- result,
226
+ }
227
+
228
+ const INSTALL_PINNED_CLAUDE_MSIX = String.raw`
229
+ $ErrorActionPreference = 'Stop'
230
+ $packagePath = $env:IMPEL_CLAUDE_MSIX_PATH
231
+ if (-not (Test-Path -LiteralPath $packagePath -PathType Leaf)) {
232
+ throw 'the verified Claude MSIX is missing'
233
+ }
234
+
235
+ $signature = Get-AuthenticodeSignature -LiteralPath $packagePath
236
+ if ($signature.Status.ToString() -cne 'Valid') {
237
+ throw "Claude MSIX signature is not valid ($($signature.Status))"
238
+ }
239
+
240
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
241
+ $archive = [System.IO.Compression.ZipFile]::OpenRead($packagePath)
242
+ try {
243
+ $manifestEntry = $archive.GetEntry('AppxManifest.xml')
244
+ if ($null -eq $manifestEntry) { throw 'Claude MSIX has no AppxManifest.xml' }
245
+ $reader = [System.IO.StreamReader]::new($manifestEntry.Open())
246
+ try { [xml]$manifest = $reader.ReadToEnd() } finally { $reader.Dispose() }
247
+ $identity = $manifest.Package.Identity
248
+ if ([string]$identity.Name -cne $env:IMPEL_CLAUDE_PACKAGE_NAME) {
249
+ throw "unexpected Claude MSIX package name: $($identity.Name)"
250
+ }
251
+ if ([string]$identity.Version -cne $env:IMPEL_CLAUDE_PACKAGE_VERSION) {
252
+ throw "unexpected Claude MSIX version: $($identity.Version)"
253
+ }
254
+ if ([string]$identity.Publisher -cne $env:IMPEL_CLAUDE_PACKAGE_PUBLISHER) {
255
+ throw 'unexpected Claude MSIX publisher'
256
+ }
257
+ if ([string]$identity.ProcessorArchitecture -cne $env:IMPEL_CLAUDE_MANIFEST_ARCHITECTURE) {
258
+ throw "unexpected Claude MSIX architecture: $($identity.ProcessorArchitecture)"
259
+ }
260
+ $application = $manifest.Package.Applications.Application | Where-Object Id -ceq 'Claude' | Select-Object -First 1
261
+ if ($null -eq $application -or [string]$application.Executable -cne $env:IMPEL_CLAUDE_PACKAGE_EXECUTABLE) {
262
+ throw 'Claude MSIX has an unexpected application entry point'
263
+ }
264
+ foreach ($entryName in @('app/claude.exe', 'app/resources/app.asar', 'AppxSignature.p7x')) {
265
+ if ($null -eq $archive.GetEntry($entryName)) {
266
+ throw "Claude MSIX is missing required entry $entryName"
267
+ }
268
+ }
269
+ } finally {
270
+ $archive.Dispose()
271
+ }
272
+
273
+ Add-AppxPackage -Path $packagePath -ForceApplicationShutdown -ForceUpdateFromAnyVersion
274
+ `;
275
+
276
+ function installPinnedWindowsClaudeMsix(packagePath, environment, pin, architecture, run = spawnSync) {
277
+ const result = run(
278
+ "powershell.exe",
279
+ ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", INSTALL_PINNED_CLAUDE_MSIX],
280
+ {
281
+ encoding: "utf8",
282
+ env: {
283
+ ...windowsPowerShellEnvironment(environment),
284
+ IMPEL_CLAUDE_MSIX_PATH: packagePath,
285
+ IMPEL_CLAUDE_PACKAGE_NAME: pin.packageName,
286
+ IMPEL_CLAUDE_PACKAGE_VERSION: pin.packageVersion,
287
+ IMPEL_CLAUDE_PACKAGE_PUBLISHER: pin.publisher,
288
+ IMPEL_CLAUDE_PACKAGE_EXECUTABLE: pin.executable,
289
+ IMPEL_CLAUDE_MANIFEST_ARCHITECTURE: architecture,
290
+ },
291
+ stdio: ["ignore", "pipe", "pipe"],
292
+ windowsHide: true,
293
+ },
294
+ );
295
+ if (result?.status !== 0 && !result?.error) {
296
+ const detail = String(result?.stderr || "").trim().split(/\r?\n/u).find(Boolean);
297
+ if (detail) return { ...result, error: new Error(`Claude MSIX installation failed: ${detail}`) };
298
+ }
299
+ return result;
300
+ }
301
+
302
+ function windowsClaudeCacheRoot(homeDir, environment, version, architecture) {
303
+ const appsRoot = environmentValue(environment, "IMPEL_APP_HOME")
304
+ || path.join(homeDir, ".config", "impel", "apps");
305
+ return path.join(appsRoot, "vendor-cache", "claude", version, architecture);
306
+ }
307
+
308
+ /** Download, verify, and install Anthropic's exact signed per-user MSIX. */
309
+ export async function ensureWindowsClaudeApp(_options = {}, dependencies = {}) {
310
+ const io = {
311
+ environment: process.env,
312
+ architecture: null,
313
+ downloadTimeoutMs: WINDOWS_CLAUDE_DOWNLOAD_TIMEOUT_MS,
314
+ fetchImpl: fetch,
315
+ findPinned: pinnedWindowsClaudeApp,
316
+ homeDir: os.homedir(),
317
+ install: installPinnedWindowsClaudeMsix,
318
+ logger: console,
319
+ pin: PINNED_VENDOR_APPS.claude,
320
+ run: spawnSync,
321
+ ...dependencies,
199
322
  };
323
+ const architecture = io.architecture || windowsClaudeArchitecture(io.environment);
324
+ const windowsPin = io.pin?.windows;
325
+ const release = windowsPin?.downloads?.[architecture];
326
+ if (!windowsPin || !release) {
327
+ const error = new Error(`Claude: no verified Windows MSIX is available for ${architecture}`);
328
+ return { binary: null, action: "install-failed", result: { status: null, error } };
329
+ }
330
+ const findPinned = () => io.findPinned(io.environment, {
331
+ architecture,
332
+ pin: windowsPin,
333
+ run: io.run,
334
+ });
335
+ const existing = findPinned();
336
+ if (existing) return { binary: existing, action: "existing", result: null };
337
+
338
+ const cacheRoot = windowsClaudeCacheRoot(io.homeDir, io.environment, io.pin.version, architecture);
339
+ const archive = path.join(cacheRoot, `Claude-${io.pin.version}-${architecture}.msix`);
340
+ const temporary = `${archive}.download-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
341
+ try {
342
+ fs.mkdirSync(cacheRoot, { recursive: true, mode: 0o700 });
343
+ if (fs.existsSync(archive) && sha256File(archive) !== release.sha256) fs.rmSync(archive, { force: true });
344
+ if (!fs.existsSync(archive)) {
345
+ io.logger.log(`Claude: downloading verified vendor app ${io.pin.version} for Windows ${architecture}…`);
346
+ await downloadWindowsClaudeMsix(release.url, temporary, io.fetchImpl, io.downloadTimeoutMs);
347
+ if (sha256File(temporary) !== release.sha256) {
348
+ throw new Error(`Claude MSIX integrity check failed for ${io.pin.version} (${architecture})`);
349
+ }
350
+ fs.renameSync(temporary, archive);
351
+ }
352
+ let result = io.install(archive, io.environment, windowsPin, architecture, io.run);
353
+ const binary = result?.status === 0 && !result?.error ? findPinned() : null;
354
+ if (result?.status === 0 && !result?.error && !binary) {
355
+ result = {
356
+ ...result,
357
+ error: new Error(`Claude ${io.pin.version} did not appear after its verified MSIX was installed`),
358
+ };
359
+ }
360
+ return {
361
+ binary,
362
+ action: binary ? "installed" : "install-failed",
363
+ result,
364
+ };
365
+ } catch (error) {
366
+ return { binary: null, action: "install-failed", result: { status: null, error } };
367
+ } finally {
368
+ fs.rmSync(temporary, { force: true });
369
+ }
200
370
  }
201
371
 
202
372
  /** Install/update OpenAI's official ChatGPT/Codex Store package. */
@@ -204,13 +374,17 @@ export function ensureWindowsChatGPTApp({ update = false } = {}, dependencies =
204
374
  const io = {
205
375
  environment: process.env,
206
376
  find: findWindowsChatGPTApp,
377
+ logger: console,
207
378
  run: spawnSync,
208
379
  ...dependencies,
209
380
  };
210
381
  const before = io.find(io.environment);
211
382
  if (before && !update) return { binary: before, action: "existing", result: null };
212
383
 
213
- const invocation = wingetInvocation(update ? "update" : "install", io.environment, {
384
+ // `impel update` also repairs missing surfaces. A missing Store package must
385
+ // be installed, not sent to `winget upgrade` (which returns 0x8A150014).
386
+ const shouldUpdate = Boolean(update && before);
387
+ const invocation = wingetInvocation(shouldUpdate ? "update" : "install", io.environment, {
214
388
  packageId: WINDOWS_CHATGPT_PACKAGE,
215
389
  source: "msstore",
216
390
  scope: null,
@@ -226,10 +400,25 @@ export function ensureWindowsChatGPTApp({ update = false } = {}, dependencies =
226
400
  result = { status: null, error };
227
401
  }
228
402
  const binary = io.find(io.environment) || before;
229
- const succeeded = result?.status === 0 && !result?.error;
403
+ // winget uses an error HRESULT when `upgrade` finds an installed package
404
+ // with no newer Store release. That is a healthy idempotent update result,
405
+ // not an installation failure (APPINSTALLER_CLI_ERROR_UPDATE_NOT_APPLICABLE).
406
+ const alreadyUpToDate = Boolean(
407
+ shouldUpdate
408
+ && binary
409
+ && !result?.error
410
+ && Number.isInteger(result?.status)
411
+ && (result.status >>> 0) === WINDOWS_WINGET_UPDATE_NOT_APPLICABLE,
412
+ );
413
+ if (alreadyUpToDate) {
414
+ io.logger.log("ChatGPT/Codex: Microsoft Store package already up to date.");
415
+ }
416
+ const succeeded = (result?.status === 0 && !result?.error) || alreadyUpToDate;
230
417
  return {
231
418
  binary,
232
- action: succeeded ? (update ? "updated" : "installed") : (before ? "existing" : "install-failed"),
419
+ action: alreadyUpToDate
420
+ ? "existing"
421
+ : succeeded ? (shouldUpdate ? "updated" : "installed") : (before ? "existing" : "install-failed"),
233
422
  result,
234
423
  };
235
424
  }