diff --git a/src/lib/office.ts b/src/lib/office.ts index b457ae2..a099a3d 100644 --- a/src/lib/office.ts +++ b/src/lib/office.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import { chmodSync, mkdirSync, rmSync } from "node:fs"; +import { chmodSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { OFFICE_DOWNLOADS_URL } from "@/lib/const.js"; import { downloadFile } from "@/lib/download.js"; @@ -196,6 +196,47 @@ function manualRunCommand(platform: OfficePlatform, script: string): string { : `bash ${script}`; } +// Windows installs are handed to the user as a right-click .cmd instead of +// being spawned from codevhub: endpoint protection (Kaspersky Endpoint +// Security in the field) silently kills a powershell child of node.exe +// mid-install, while the very same script from the very same folder finishes +// when the user launches it themselves. The wrapper reproduces that +// verified-good ancestry (explorer -> cmd -> powershell) and needs nothing +// typed. Exported for tests. +export function officeWrapperName(uninstall: boolean): string { + return uninstall ? "Uninstall-CoDev-Office.cmd" : "Install-CoDev-Office.cmd"; +} + +export function officeWrapperContent(script: string, args: string[]): string { + // Self-elevating so a plain DOUBLE-CLICK is enough (some environments strip + // "Run as administrator" from the context menu): when not elevated, the + // .cmd relaunches itself elevated via a UAC prompt. If elevation is + // declined or unavailable (non-admin account), it continues non-elevated — + // the setup script supports that: each component installer raises its own + // permission prompt, and a declined one only skips that component (fatal + // for the .NET SDK alone). + // %~dp0 = the .cmd's own folder (an elevated relaunch starts in System32); + // `pause` keeps the window open so the closing "Verification passed" (or a + // [FAIL] line) stays readable. + const argStr = args.map((a) => ` ${a}`).join(""); + return [ + "@echo off", + 'cd /d "%~dp0"', + "net session >nul 2>&1", + "if not errorlevel 1 goto :run", + "echo Requesting administrator rights - choose Yes in the prompt...", + "powershell -NoProfile -Command \"Start-Process -FilePath '%~f0' -Verb RunAs\" >nul 2>&1", + "if not errorlevel 1 exit /b 0", + "echo Continuing without administrator rights - each installer will ask for permission separately.", + "echo.", + ":run", + `powershell -ExecutionPolicy Bypass -File ".\\${script}"${argStr}`, + "echo.", + "pause", + "", + ].join("\r\n"); +} + export function installerArgs( parsed: OfficeArgs, platform: OfficePlatform, @@ -339,6 +380,55 @@ export async function runSkillOffice( chmodSync(join(dir, script), 0o755); } + const scriptArgs = parsed.uninstall + ? uninstallerArgs(parsed, platform) + : installerArgs(parsed, platform); + + // Windows: stage a right-click wrapper and stop — see officeWrapperName. + if (platform === "windows") { + const wrapper = officeWrapperName(parsed.uninstall); + writeFileSync(join(dir, wrapper), officeWrapperContent(script, scriptArgs)); + const verb = parsed.uninstall ? "uninstaller" : "installer"; + console.error(`\nFiles are in ${dir}.`); + console.error( + `codevhub does not auto-run the Windows ${verb}: endpoint protection ` + + "(e.g. Kaspersky) is known to silently kill installers it launches. Instead:", + ); + console.error( + ` 1. Open that folder in File Explorer (opened for you if possible)`, + ); + console.error(` 2. Double-click ${wrapper}`); + console.error( + ` 3. Choose "Yes" when Windows asks for administrator permission`, + ); + console.error( + ` (no admin rights? choose "No" - the install continues and each component asks for permission separately)`, + ); + console.error( + ` 4. Wait for the closing message - the window stays open when done`, + ); + if (hostPlatform === "windows") { + try { + const explorer = spawn("explorer.exe", [dir], { + detached: true, + stdio: "ignore", + }); + // Best-effort convenience: spawn failures surface as an async + // "error" event (which would crash the process if unhandled), + // not as a throw — the printed path is enough either way. + explorer.on("error", () => {}); + explorer.unref(); + } catch { + // Same best-effort stance for synchronous spawn failures. + } + } + logInfo("office windows handoff staged", { + action: parsed.uninstall ? "office.uninstall" : "office.install", + extra: { platform, dir, wrapper }, + }); + return 0; + } + if (downloadOnly) { console.error( `\nFiles are in ${dir}. To ${parsed.uninstall ? "uninstall" : "install"}, run from that folder:`, @@ -346,26 +436,13 @@ export async function runSkillOffice( console.error(` ${manualRunCommand(platform, script)}`); return 0; } - - const scriptArgs = parsed.uninstall - ? uninstallerArgs(parsed, platform) - : installerArgs(parsed, platform); console.error( `\nRunning the ${parsed.uninstall ? "uninstaller" : "installer"} (${script})...\n`, ); - const [command, args] = - platform === "windows" - ? [ - "powershell.exe", - [ - "-ExecutionPolicy", - "Bypass", - "-File", - join(dir, script), - ...scriptArgs, - ], - ] - : ["bash", [join(dir, script), ...scriptArgs]]; + // Windows returned above with the right-click wrapper — only the bash + // platforms reach the spawn. + const command = "bash"; + const args = [join(dir, script), ...scriptArgs]; // cwd = download dir: the scripts locate their bundle zip next to // themselves/CWD, and the staged files are there. const code = await spawner(command, args, dir); diff --git a/tests/lib/download.test.ts b/tests/lib/download.test.ts index 3ae7ba2..492d444 100644 --- a/tests/lib/download.test.ts +++ b/tests/lib/download.test.ts @@ -15,6 +15,8 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { downloadFile } from "@/lib/download.js"; import { installerArgs, + officeWrapperContent, + officeWrapperName, runSkillOffice, uninstallerArgs, } from "@/lib/office.js"; @@ -393,6 +395,60 @@ describe("runSkillOffice", () => { expect(existsSync(join(dir, "codev-office-windows.zip"))).toBe(true); }); + test("windows staging writes the right-click wrapper, flags baked in, never spawns", async () => { + // Endpoint protection (KES) silently kills installers spawned by + // codevhub on Windows, so the windows flow must never spawn - it + // stages a .cmd the user runs via right-click -> Run as administrator. + objects.set("/codev-office-windows.zip", BUNDLE); + objects.set("/codev-office-windows-setup.ps1", SCRIPT); + const dir = join(tempDir, "office"); + const spawns: string[] = []; + const code = await runSkillOffice( + [ + "--platform", + "windows", + "--dir", + dir, + "--skip-verify", + "--force-skills", + ], + baseUrl, + async (command) => { + spawns.push(command); + return 0; + }, + ); + expect(code).toBe(0); + expect(spawns).toEqual([]); + const wrapper = readFileSync(join(dir, "Install-CoDev-Office.cmd"), "utf8"); + expect(wrapper).toContain( + 'powershell -ExecutionPolicy Bypass -File ".\\codev-office-windows-setup.ps1" -SkipVerify -ForceSkills', + ); + expect(wrapper).toContain("pause"); + expect(wrapper).toContain('cd /d "%~dp0"'); + // Self-elevation: a plain double-click must request admin rights itself + // (some environments strip "Run as administrator" from the context + // menu) and fall back to a non-elevated run when declined. + expect(wrapper).toContain("net session"); + expect(wrapper).toContain("Start-Process -FilePath '%~f0' -Verb RunAs"); + expect(wrapper).toContain(":run"); + expect(wrapper).toContain("Continuing without administrator rights"); + }); + + test("wrapper name and content cover the uninstall flow", () => { + expect(officeWrapperName(false)).toBe("Install-CoDev-Office.cmd"); + expect(officeWrapperName(true)).toBe("Uninstall-CoDev-Office.cmd"); + const content = officeWrapperContent("codev-office-windows-uninstall.ps1", [ + "-Yes", + "-SkillsOnly", + ]); + expect(content).toContain( + 'powershell -ExecutionPolicy Bypass -File ".\\codev-office-windows-uninstall.ps1" -Yes -SkillsOnly', + ); + // CRLF line endings - the file must open cleanly in cmd.exe. + expect(content).toContain("\r\n"); + }); + test("always refetches the setup script, but reuses a finished bundle", async () => { const dir = join(tempDir, "office"); mkdirSync(dir, { recursive: true }); @@ -428,7 +484,11 @@ describe("runSkillOffice", () => { // The PowerShell branch is unreachable from a non-Windows host — a // cross-platform --platform forces download-only — so the host is stubbed to // keep the argv shape pinned on the Linux/macOS machines that run this suite. - test("runs the PowerShell installer on a Windows host", async () => { + test("a Windows host stages the right-click wrapper instead of spawning", async () => { + // Endpoint protection (KES in the field) silently kills a powershell + // child of node.exe mid-install, so the installer must never be + // spawned from codevhub on Windows — the user launches the staged + // .cmd via right-click -> Run as administrator instead. objects.set("/codev-office-windows.zip", BUNDLE); objects.set("/codev-office-windows-setup.ps1", SCRIPT); const dir = join(tempDir, "office"); @@ -444,17 +504,11 @@ describe("runSkillOffice", () => { ), ); expect(code).toBe(0); - expect(spawned).toEqual({ - command: "powershell.exe", - args: [ - "-ExecutionPolicy", - "Bypass", - "-File", - join(dir, "codev-office-windows-setup.ps1"), - "-SkipVerify", - ], - cwd: dir, - }); + expect(spawned).toBeNull(); + const wrapper = readFileSync(join(dir, "Install-CoDev-Office.cmd"), "utf8"); + expect(wrapper).toContain( + 'powershell -ExecutionPolicy Bypass -File ".\\codev-office-windows-setup.ps1" -SkipVerify', + ); }); test("exits 1 on an OS with no bundle and downloads nothing", async () => {